protect-mcp 0.6.0 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,11 @@
8
8
  > A policy check that sits between your AI agent and the tools it calls.
9
9
  > Every tool call is evaluated against a rule you wrote. Every decision is signed.
10
10
 
11
+ > **Receipt format:** `protect-mcp` emits Veritas Acta receipts. Legacy
12
+ > ScopeBlind receipts remain verifiable, but Acta v0.1 is the canonical
13
+ > format going forward. Spec: [`@veritasacta/protocol`](https://www.npmjs.com/package/@veritasacta/protocol)
14
+ > · IETF: [draft-farley-acta-signed-receipts](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/).
15
+
11
16
  ## What it does, in plain English
12
17
 
13
18
  When an AI agent (Claude Code, Cursor, a custom LangChain app, anything that
@@ -335,6 +340,42 @@ Free tier: 20,000 receipts/month. No credit card required.
335
340
 
336
341
  [scopeblind.com/pricing](https://scopeblind.com/pricing)
337
342
 
343
+ ### ScopeBlind tenant integration (Founding Plan)
344
+
345
+ If you have a ScopeBlind Founding Plan tenant, set your `SCOPEBLIND_TOKEN`
346
+ (from the welcome email) in the environment and protect-mcp forwards every
347
+ signed receipt to your dashboard at `https://scopeblind.com/console/<your-slug>`.
348
+
349
+ ```bash
350
+ # Your protect-mcp install with cloud-synced receipts
351
+ SCOPEBLIND_TOKEN=scp_... \
352
+ npx protect-mcp init-hooks
353
+ ```
354
+
355
+ How it works:
356
+
357
+ 1. On first receipt, the bridge exchanges your token for a short-lived BRASS-v2
358
+ auth proof at `/fn/brass/issue` (ECDSA P-256 signed, hourly expiry).
359
+ 2. Receipts are batched and POSTed to `/fn/console/<slug>/receipts` every 5
360
+ seconds (up to 128 per batch).
361
+ 3. The local `.receipts/` chain remains authoritative regardless of forward
362
+ status. Network failures are retried; quota exhaustion is reported. No
363
+ crash, no blocking.
364
+
365
+ Quota: founding tier 10,000 receipts/day, enterprise 100,000/day. Anything
366
+ above quota is rejected with a structured response; receipts stay safe in
367
+ `.receipts/` until you upgrade or until tomorrow.
368
+
369
+ Receipt verification by third parties:
370
+
371
+ ```bash
372
+ # Anyone can verify your receipts offline using the public JWKS
373
+ curl https://scopeblind.com/.well-known/jwks.json
374
+ npx @veritasacta/verify .receipts/0001.json
375
+ ```
376
+
377
+ Verification is independent of ScopeBlind — the math doesn't care who runs it.
378
+
338
379
  ## Interoperability
339
380
 
340
381
  The receipt format is independently implemented and verified across multiple systems:
@@ -344,7 +385,7 @@ The receipt format is independently implemented and verified across multiple sys
344
385
  | **4 independent implementations** | TypeScript (protect-mcp), Python (protect-mcp-adk), Rust (Cedar WASM), APS ProxyGateway |
345
386
  | **2 IETF Internet-Drafts** | [draft-farley-acta-signed-receipts-01](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/), [draft-pidlisnyi-aps-00](https://datatracker.ietf.org/doc/draft-pidlisnyi-aps/) |
346
387
  | **8 cross-engine receipts** | [Composition test](https://github.com/ScopeBlind/examples/tree/main/interop/composition-test): 2 engines, 1 verifier, all VALID |
347
- | **3 enterprise integrations** | Microsoft AGT [#667](https://github.com/microsoft/agent-governance-toolkit/pull/667), [#1159](https://github.com/microsoft/agent-governance-toolkit/pull/1159), [#1168](https://github.com/microsoft/agent-governance-toolkit/pull/1168) |
388
+ | **PRs merged into microsoft/agent-governance-toolkit** | [#667](https://github.com/microsoft/agent-governance-toolkit/pull/667), [#1159](https://github.com/microsoft/agent-governance-toolkit/pull/1159), [#1168](https://github.com/microsoft/agent-governance-toolkit/pull/1168), [#1186](https://github.com/microsoft/agent-governance-toolkit/pull/1186), [#1197](https://github.com/microsoft/agent-governance-toolkit/pull/1197), [#1202](https://github.com/microsoft/agent-governance-toolkit/pull/1202), [#1203](https://github.com/microsoft/agent-governance-toolkit/pull/1203), [#1205](https://github.com/microsoft/agent-governance-toolkit/pull/1205) |
348
389
  | **1 verifier, zero dependencies** | `npx @veritasacta/verify receipt.json --key <hex>` (Apache-2.0, offline) |
349
390
 
350
391
  Verify any receipt from any implementation:
@@ -11,13 +11,166 @@ import {
11
11
  loadPolicy,
12
12
  parseRateLimit,
13
13
  signDecision
14
- } from "./chunk-YTBC72JJ.mjs";
14
+ } from "./chunk-UV53U6D4.mjs";
15
15
 
16
16
  // src/hook-server.ts
17
17
  import { createServer } from "http";
18
18
  import { createHash, randomUUID, randomBytes } from "crypto";
19
19
  import { appendFileSync, readFileSync, existsSync, readdirSync } from "fs";
20
20
  import { join } from "path";
21
+
22
+ // src/scopeblind-bridge.ts
23
+ var DEFAULT_BASE = "https://scopeblind.com";
24
+ var FLUSH_INTERVAL_MS = 5e3;
25
+ var BATCH_MAX = 128;
26
+ var BRASS_REFRESH_MARGIN_MS = 5 * 60 * 1e3;
27
+ var ScopeBlindBridge = class {
28
+ token;
29
+ base;
30
+ tenantOverride;
31
+ cachedProof = null;
32
+ queue = [];
33
+ flushTimer = null;
34
+ stats;
35
+ shuttingDown = false;
36
+ constructor(env = process.env) {
37
+ this.token = env.SCOPEBLIND_TOKEN || null;
38
+ this.base = (env.SCOPEBLIND_BASE || DEFAULT_BASE).replace(/\/$/, "");
39
+ this.tenantOverride = env.SCOPEBLIND_TENANT || null;
40
+ this.stats = {
41
+ enabled: Boolean(this.token),
42
+ tenant_slug: this.tenantOverride,
43
+ forwarded_total: 0,
44
+ rejected_total: 0,
45
+ last_flush_at: null,
46
+ last_error: null
47
+ };
48
+ if (this.enabled()) {
49
+ this.flushTimer = setInterval(() => {
50
+ void this.flush();
51
+ }, FLUSH_INTERVAL_MS);
52
+ if (typeof this.flushTimer === "object" && this.flushTimer && "unref" in this.flushTimer) {
53
+ this.flushTimer.unref?.();
54
+ }
55
+ process.on("beforeExit", () => {
56
+ void this.shutdown();
57
+ });
58
+ }
59
+ }
60
+ enabled() {
61
+ return Boolean(this.token);
62
+ }
63
+ /** Push a signed receipt into the queue. Non-blocking. */
64
+ forward(signedReceipt) {
65
+ if (!this.enabled() || this.shuttingDown) return;
66
+ this.queue.push(signedReceipt);
67
+ if (this.queue.length >= BATCH_MAX) void this.flush();
68
+ }
69
+ /** Flush the queue. Safe to call concurrently. */
70
+ async flush() {
71
+ if (!this.enabled() || this.queue.length === 0) return;
72
+ const batch = this.queue.splice(0, BATCH_MAX);
73
+ try {
74
+ const proof = await this.ensureBrassProof();
75
+ const slug = this.tenantOverride || proof?.tenant_id;
76
+ if (!slug) {
77
+ this.queue.unshift(...batch);
78
+ return;
79
+ }
80
+ this.stats.tenant_slug = slug;
81
+ const res = await fetch(`${this.base}/fn/console/${slug}/receipts`, {
82
+ method: "POST",
83
+ headers: {
84
+ "content-type": "application/json",
85
+ authorization: `Bearer ${this.token}`,
86
+ "user-agent": "protect-mcp/scopeblind-bridge"
87
+ },
88
+ body: JSON.stringify({ receipts: batch })
89
+ });
90
+ if (!res.ok) {
91
+ const errBody = await res.text().catch(() => "");
92
+ this.stats.last_error = `HTTP ${res.status} ${errBody.slice(0, 160)}`;
93
+ this.stats.rejected_total += batch.length;
94
+ if (res.status >= 500 && res.status !== 503) {
95
+ this.queue.unshift(...batch);
96
+ }
97
+ return;
98
+ }
99
+ const body = await res.json().catch(() => ({}));
100
+ this.stats.forwarded_total += body?.accepted ?? batch.length;
101
+ this.stats.rejected_total += body?.rejected ?? 0;
102
+ this.stats.last_flush_at = (/* @__PURE__ */ new Date()).toISOString();
103
+ this.stats.last_error = null;
104
+ } catch (err) {
105
+ this.stats.last_error = String(err?.message || err);
106
+ this.queue.unshift(...batch);
107
+ }
108
+ }
109
+ /** Exchange SCOPEBLIND_TOKEN for a BRASS-v2 proof; refresh near expiry. */
110
+ async ensureBrassProof() {
111
+ if (!this.token) return null;
112
+ const now = Date.now();
113
+ if (this.cachedProof && Date.parse(this.cachedProof.expires_at) - now > BRASS_REFRESH_MARGIN_MS) {
114
+ return this.cachedProof;
115
+ }
116
+ try {
117
+ const res = await fetch(`${this.base}/fn/brass/issue`, {
118
+ method: "POST",
119
+ headers: {
120
+ "content-type": "application/json",
121
+ "user-agent": "protect-mcp/scopeblind-bridge"
122
+ },
123
+ body: JSON.stringify({
124
+ token: this.token,
125
+ scope: "protect-mcp-receipt-emit",
126
+ ttl_seconds: 3600
127
+ })
128
+ });
129
+ if (!res.ok) {
130
+ const text = await res.text().catch(() => "");
131
+ this.stats.last_error = `brass-issue: HTTP ${res.status} ${text.slice(0, 160)}`;
132
+ return null;
133
+ }
134
+ const body = await res.json();
135
+ if (!body?.auth_proof) {
136
+ this.stats.last_error = "brass-issue: missing auth_proof in response";
137
+ return null;
138
+ }
139
+ this.cachedProof = body.auth_proof;
140
+ return this.cachedProof;
141
+ } catch (err) {
142
+ this.stats.last_error = `brass-issue: ${err?.message || err}`;
143
+ return null;
144
+ }
145
+ }
146
+ /**
147
+ * Return a snapshot of bridge stats. Useful for `protect-mcp scopeblind status`.
148
+ */
149
+ getStats() {
150
+ return {
151
+ ...this.stats,
152
+ queued: this.queue.length,
153
+ brass_proof_expires_at: this.cachedProof?.expires_at || null
154
+ };
155
+ }
156
+ /** Flush remaining receipts and stop the interval. Called on process exit. */
157
+ async shutdown() {
158
+ if (this.shuttingDown) return;
159
+ this.shuttingDown = true;
160
+ if (this.flushTimer) clearInterval(this.flushTimer);
161
+ if (this.queue.length > 0) await this.flush();
162
+ }
163
+ };
164
+ var singleton = null;
165
+ function getScopeBlindBridge() {
166
+ if (!singleton) singleton = new ScopeBlindBridge();
167
+ return singleton;
168
+ }
169
+ function forwardReceipt(signedReceipt) {
170
+ getScopeBlindBridge().forward(signedReceipt);
171
+ }
172
+
173
+ // src/hook-server.ts
21
174
  var DEFAULT_PORT = 9377;
22
175
  var LOG_FILE = ".protect-mcp-log.jsonl";
23
176
  var RECEIPTS_FILE = ".protect-mcp-receipts.jsonl";
@@ -225,7 +378,7 @@ async function handlePreToolUse(input, state) {
225
378
  const hookLatency = Date.now() - hookStart;
226
379
  const denyKey = `${toolName}:${input.sessionId || "default"}`;
227
380
  state.denyCounter.delete(denyKey);
228
- emitDecisionLog(state, {
381
+ const emit = emitDecisionLog(state, {
229
382
  tool: toolName,
230
383
  decision: "allow",
231
384
  reason_code: state.cedarPolicies ? "cedar_allow" : state.jsonPolicy ? "policy_allow" : "observe_mode",
@@ -237,6 +390,15 @@ async function handlePreToolUse(input, state) {
237
390
  sandbox_state: detectSandboxState(),
238
391
  plan_receipt_id: state.activePlanReceiptId || void 0
239
392
  });
393
+ if (state.enforce && emit.signingFailed) {
394
+ return {
395
+ hookSpecificOutput: {
396
+ hookEventName: "PreToolUse",
397
+ permissionDecision: "deny",
398
+ permissionDecisionReason: `[ScopeBlind] "${toolName}" was blocked because its receipt could not be signed. Failing closed: a governed action that cannot be proven is not allowed.`
399
+ }
400
+ };
401
+ }
240
402
  return {};
241
403
  }
242
404
  async function handlePostToolUse(input, state) {
@@ -484,11 +646,35 @@ function emitDecisionLog(state, entry) {
484
646
  } catch {
485
647
  }
486
648
  state.receiptBuffer.add(log.request_id, signed.signed);
487
- } else if (signed.warning) {
488
- process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
649
+ try {
650
+ const bridge = getScopeBlindBridge();
651
+ if (bridge.enabled()) {
652
+ const parsed = typeof signed.signed === "string" ? JSON.parse(signed.signed) : signed.signed;
653
+ bridge.forward(parsed);
654
+ }
655
+ } catch (err) {
656
+ process.stderr.write(`[PROTECT_MCP] ScopeBlind forward error: ${err instanceof Error ? err.message : err}
657
+ `);
658
+ }
659
+ } else if (signed.error) {
660
+ const tombstone = JSON.stringify({
661
+ type: "scopeblind.signing_failure.v1",
662
+ request_id: log.request_id,
663
+ tool: log.tool,
664
+ decision: log.decision,
665
+ error: signed.error,
666
+ at: new Date(log.timestamp).toISOString()
667
+ });
668
+ try {
669
+ appendFileSync(state.receiptFilePath, tombstone + "\n");
670
+ } catch {
671
+ }
672
+ process.stderr.write(`[PROTECT_MCP_SIGNING_FAILURE] ${tombstone}
489
673
  `);
674
+ return { signingFailed: true };
490
675
  }
491
676
  }
677
+ return { signingFailed: false };
492
678
  }
493
679
  async function routeHookEvent(input, state) {
494
680
  switch (input.hookEventName) {
@@ -828,5 +1014,38 @@ function normalizeHookInput(raw) {
828
1014
  }
829
1015
 
830
1016
  export {
1017
+ ScopeBlindBridge,
1018
+ getScopeBlindBridge,
1019
+ forwardReceipt,
831
1020
  startHookServer
832
1021
  };
1022
+ /**
1023
+ * scopeblind-bridge.ts
1024
+ *
1025
+ * Optional bridge between protect-mcp (local, MIT) and a paid ScopeBlind
1026
+ * tenant. When SCOPEBLIND_TOKEN is set in the environment, every signed
1027
+ * receipt that protect-mcp emits also gets forwarded to the tenant's
1028
+ * dashboard at https://scopeblind.com/console/<slug>.
1029
+ *
1030
+ * Lifecycle:
1031
+ * 1. On first use, exchange SCOPEBLIND_TOKEN for a short-lived BRASS-v2
1032
+ * auth proof from /fn/brass/issue. Cache the proof in memory until
1033
+ * ~5 minutes before expiry, then refresh.
1034
+ * 2. As receipts are emitted by hook-server.ts, push them into an
1035
+ * in-memory batch queue.
1036
+ * 3. Flush the queue every 5s (or when it reaches 128 receipts) by POSTing
1037
+ * to /fn/console/<slug>/receipts with Bearer SCOPEBLIND_TOKEN.
1038
+ *
1039
+ * Failure mode: forward errors NEVER throw upstream. protect-mcp continues
1040
+ * to mint and persist receipts locally regardless of dashboard availability.
1041
+ * The bridge logs failures to stderr (best-effort) and retries on the next
1042
+ * flush.
1043
+ *
1044
+ * Configuration:
1045
+ * SCOPEBLIND_TOKEN Tenant bearer token (from welcome email).
1046
+ * SCOPEBLIND_TENANT Optional slug override. By default we discover
1047
+ * the slug from the BRASS proof's tenant_id.
1048
+ * SCOPEBLIND_BASE Defaults to https://scopeblind.com.
1049
+ *
1050
+ * @license MIT
1051
+ */
@@ -7,7 +7,7 @@ import {
7
7
  parseRateLimit,
8
8
  signDecision,
9
9
  startStatusServer
10
- } from "./chunk-YTBC72JJ.mjs";
10
+ } from "./chunk-UV53U6D4.mjs";
11
11
 
12
12
  // src/evidence-store.ts
13
13
  import { readFileSync, writeFileSync, existsSync } from "fs";
@@ -974,8 +974,20 @@ var ProtectGateway = class {
974
974
  this.evidenceStore.save();
975
975
  }
976
976
  }
977
- } else if (signed.warning) {
978
- process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
977
+ } else if (signed.error) {
978
+ const tombstone = JSON.stringify({
979
+ type: "scopeblind.signing_failure.v1",
980
+ request_id: log.request_id,
981
+ tool: log.tool,
982
+ decision: log.decision,
983
+ error: signed.error,
984
+ at: new Date(log.timestamp).toISOString()
985
+ });
986
+ try {
987
+ appendFileSync(this.receiptFilePath, tombstone + "\n");
988
+ } catch {
989
+ }
990
+ process.stderr.write(`[PROTECT_MCP_SIGNING_FAILURE] ${tombstone}
979
991
  `);
980
992
  }
981
993
  }
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  meetsMinTier
3
- } from "./chunk-BYYWYSHM.mjs";
3
+ } from "./chunk-PLKRTBDR.mjs";
4
4
  import {
5
5
  checkRateLimit,
6
6
  getToolPolicy,
7
7
  parseRateLimit
8
- } from "./chunk-YTBC72JJ.mjs";
8
+ } from "./chunk-UV53U6D4.mjs";
9
9
 
10
10
  // src/simulate.ts
11
11
  import { readFileSync } from "fs";
@@ -78,11 +78,40 @@ function checkRateLimit(key, limit, store) {
78
78
  import { readFileSync as readFileSync2, existsSync } from "fs";
79
79
  var signerState = null;
80
80
  var artifactsModule = null;
81
+ var signingConfigured = false;
82
+ var signingInitError = null;
81
83
  async function initSigning(config) {
82
84
  const warnings = [];
85
+ signerState = null;
86
+ artifactsModule = null;
87
+ signingConfigured = Boolean(config && config.enabled !== false);
88
+ signingInitError = null;
83
89
  if (!config || config.enabled === false) {
84
90
  return warnings;
85
91
  }
92
+ if (!config.key_path) {
93
+ signingInitError = "signing enabled but key_path is not configured";
94
+ warnings.push(`signing: ${signingInitError}`);
95
+ return warnings;
96
+ }
97
+ if (!existsSync(config.key_path)) {
98
+ signingInitError = `key file not found at ${config.key_path}`;
99
+ warnings.push(`signing: ${signingInitError} \u2014 run "protect-mcp init" to generate`);
100
+ return warnings;
101
+ }
102
+ let keyData;
103
+ try {
104
+ keyData = JSON.parse(readFileSync2(config.key_path, "utf-8"));
105
+ if (!keyData.privateKey || !keyData.publicKey) {
106
+ signingInitError = "key file missing privateKey or publicKey fields";
107
+ warnings.push(`signing: ${signingInitError}`);
108
+ return warnings;
109
+ }
110
+ } catch (err) {
111
+ signingInitError = `failed to load key file: ${err instanceof Error ? err.message : err}`;
112
+ warnings.push(`signing: ${signingInitError}`);
113
+ return warnings;
114
+ }
86
115
  try {
87
116
  const moduleName = "@veritasacta/artifacts";
88
117
  artifactsModule = await import(
@@ -90,37 +119,48 @@ async function initSigning(config) {
90
119
  moduleName
91
120
  );
92
121
  } catch {
93
- warnings.push("signing: @veritasacta/artifacts not available \u2014 receipts will be unsigned");
122
+ signingInitError = "@veritasacta/artifacts not available";
123
+ warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
94
124
  return warnings;
95
125
  }
96
- if (config.key_path) {
97
- if (!existsSync(config.key_path)) {
98
- warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
99
- return warnings;
100
- }
101
- try {
102
- const keyData = JSON.parse(readFileSync2(config.key_path, "utf-8"));
103
- if (!keyData.privateKey || !keyData.publicKey) {
104
- warnings.push("signing: key file missing privateKey or publicKey fields");
105
- return warnings;
106
- }
107
- signerState = {
108
- privateKey: keyData.privateKey,
109
- publicKey: keyData.publicKey,
110
- kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
111
- issuer: config.issuer || keyData.issuer || "protect-mcp"
112
- };
113
- } catch (err) {
114
- warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
115
- }
126
+ try {
127
+ signerState = {
128
+ privateKey: keyData.privateKey,
129
+ publicKey: keyData.publicKey,
130
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
131
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
132
+ };
133
+ } catch (err) {
134
+ signingInitError = `failed to initialize signer: ${err instanceof Error ? err.message : err}`;
135
+ artifactsModule = null;
136
+ warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
116
137
  }
117
138
  return warnings;
118
139
  }
119
140
  function signDecision(entry) {
141
+ const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
142
+ if (signingConfigured && signingInitError) {
143
+ return {
144
+ ok: false,
145
+ signed: null,
146
+ artifact_type: artifactType,
147
+ warning: `signing initialization failed: ${signingInitError}`,
148
+ error: signingInitError
149
+ };
150
+ }
151
+ if (signingConfigured && (!signerState || !artifactsModule)) {
152
+ const error = "signing was configured but no signer is ready";
153
+ return {
154
+ ok: false,
155
+ signed: null,
156
+ artifact_type: artifactType,
157
+ warning: error,
158
+ error
159
+ };
160
+ }
120
161
  if (!signerState || !artifactsModule) {
121
- return { signed: null, artifact_type: "none" };
162
+ return { ok: false, signed: null, artifact_type: "none" };
122
163
  }
123
- const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
124
164
  try {
125
165
  const payload = {
126
166
  tool: entry.tool,
@@ -161,14 +201,18 @@ function signDecision(entry) {
161
201
  }
162
202
  );
163
203
  return {
204
+ ok: true,
164
205
  signed: JSON.stringify(result.artifact),
165
206
  artifact_type: artifactType
166
207
  };
167
208
  } catch (err) {
209
+ const message = err instanceof Error ? err.message : "unknown error";
168
210
  return {
211
+ ok: false,
169
212
  signed: null,
170
213
  artifact_type: artifactType,
171
- warning: `signing failed: ${err instanceof Error ? err.message : "unknown error"}`
214
+ warning: `signing failed: ${message}`,
215
+ error: message
172
216
  };
173
217
  }
174
218
  }
@@ -181,7 +225,7 @@ function getSignerInfo() {
181
225
  };
182
226
  }
183
227
  function isSigningEnabled() {
184
- return signerState !== null && artifactsModule !== null;
228
+ return signingConfigured && signingInitError === null && signerState !== null && artifactsModule !== null;
185
229
  }
186
230
 
187
231
  // src/cedar-evaluator.ts