protect-mcp 0.6.0 → 0.6.2

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/dist/cli.js CHANGED
@@ -361,9 +361,36 @@ var init_credentials = __esm({
361
361
  // src/signing.ts
362
362
  async function initSigning(config) {
363
363
  const warnings = [];
364
+ signerState = null;
365
+ artifactsModule = null;
366
+ signingConfigured = Boolean(config && config.enabled !== false);
367
+ signingInitError = null;
364
368
  if (!config || config.enabled === false) {
365
369
  return warnings;
366
370
  }
371
+ if (!config.key_path) {
372
+ signingInitError = "signing enabled but key_path is not configured";
373
+ warnings.push(`signing: ${signingInitError}`);
374
+ return warnings;
375
+ }
376
+ if (!(0, import_node_fs3.existsSync)(config.key_path)) {
377
+ signingInitError = `key file not found at ${config.key_path}`;
378
+ warnings.push(`signing: ${signingInitError} \u2014 run "protect-mcp init" to generate`);
379
+ return warnings;
380
+ }
381
+ let keyData;
382
+ try {
383
+ keyData = JSON.parse((0, import_node_fs3.readFileSync)(config.key_path, "utf-8"));
384
+ if (!keyData.privateKey || !keyData.publicKey) {
385
+ signingInitError = "key file missing privateKey or publicKey fields";
386
+ warnings.push(`signing: ${signingInitError}`);
387
+ return warnings;
388
+ }
389
+ } catch (err) {
390
+ signingInitError = `failed to load key file: ${err instanceof Error ? err.message : err}`;
391
+ warnings.push(`signing: ${signingInitError}`);
392
+ return warnings;
393
+ }
367
394
  try {
368
395
  const moduleName = "@veritasacta/artifacts";
369
396
  artifactsModule = await import(
@@ -371,37 +398,48 @@ async function initSigning(config) {
371
398
  moduleName
372
399
  );
373
400
  } catch {
374
- warnings.push("signing: @veritasacta/artifacts not available \u2014 receipts will be unsigned");
401
+ signingInitError = "@veritasacta/artifacts not available";
402
+ warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
375
403
  return warnings;
376
404
  }
377
- if (config.key_path) {
378
- if (!(0, import_node_fs3.existsSync)(config.key_path)) {
379
- warnings.push(`signing: key file not found at ${config.key_path} \u2014 run "protect-mcp init" to generate`);
380
- return warnings;
381
- }
382
- try {
383
- const keyData = JSON.parse((0, import_node_fs3.readFileSync)(config.key_path, "utf-8"));
384
- if (!keyData.privateKey || !keyData.publicKey) {
385
- warnings.push("signing: key file missing privateKey or publicKey fields");
386
- return warnings;
387
- }
388
- signerState = {
389
- privateKey: keyData.privateKey,
390
- publicKey: keyData.publicKey,
391
- kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
392
- issuer: config.issuer || keyData.issuer || "protect-mcp"
393
- };
394
- } catch (err) {
395
- warnings.push(`signing: failed to load key file: ${err instanceof Error ? err.message : err}`);
396
- }
405
+ try {
406
+ signerState = {
407
+ privateKey: keyData.privateKey,
408
+ publicKey: keyData.publicKey,
409
+ kid: keyData.kid || artifactsModule.computeKid(keyData.publicKey),
410
+ issuer: config.issuer || keyData.issuer || "protect-mcp"
411
+ };
412
+ } catch (err) {
413
+ signingInitError = `failed to initialize signer: ${err instanceof Error ? err.message : err}`;
414
+ artifactsModule = null;
415
+ warnings.push(`signing: ${signingInitError} \u2014 enforce mode will fail closed`);
397
416
  }
398
417
  return warnings;
399
418
  }
400
419
  function signDecision(entry) {
420
+ const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
421
+ if (signingConfigured && signingInitError) {
422
+ return {
423
+ ok: false,
424
+ signed: null,
425
+ artifact_type: artifactType,
426
+ warning: `signing initialization failed: ${signingInitError}`,
427
+ error: signingInitError
428
+ };
429
+ }
430
+ if (signingConfigured && (!signerState || !artifactsModule)) {
431
+ const error = "signing was configured but no signer is ready";
432
+ return {
433
+ ok: false,
434
+ signed: null,
435
+ artifact_type: artifactType,
436
+ warning: error,
437
+ error
438
+ };
439
+ }
401
440
  if (!signerState || !artifactsModule) {
402
- return { signed: null, artifact_type: "none" };
441
+ return { ok: false, signed: null, artifact_type: "none" };
403
442
  }
404
- const artifactType = entry.decision === "deny" ? "gateway_restraint" : "decision_receipt";
405
443
  try {
406
444
  const payload = {
407
445
  tool: entry.tool,
@@ -442,14 +480,18 @@ function signDecision(entry) {
442
480
  }
443
481
  );
444
482
  return {
483
+ ok: true,
445
484
  signed: JSON.stringify(result.artifact),
446
485
  artifact_type: artifactType
447
486
  };
448
487
  } catch (err) {
488
+ const message = err instanceof Error ? err.message : "unknown error";
449
489
  return {
490
+ ok: false,
450
491
  signed: null,
451
492
  artifact_type: artifactType,
452
- warning: `signing failed: ${err instanceof Error ? err.message : "unknown error"}`
493
+ warning: `signing failed: ${message}`,
494
+ error: message
453
495
  };
454
496
  }
455
497
  }
@@ -462,15 +504,17 @@ function getSignerInfo() {
462
504
  };
463
505
  }
464
506
  function isSigningEnabled() {
465
- return signerState !== null && artifactsModule !== null;
507
+ return signingConfigured && signingInitError === null && signerState !== null && artifactsModule !== null;
466
508
  }
467
- var import_node_fs3, signerState, artifactsModule;
509
+ var import_node_fs3, signerState, artifactsModule, signingConfigured, signingInitError;
468
510
  var init_signing = __esm({
469
511
  "src/signing.ts"() {
470
512
  "use strict";
471
513
  import_node_fs3 = require("fs");
472
514
  signerState = null;
473
515
  artifactsModule = null;
516
+ signingConfigured = false;
517
+ signingInitError = null;
474
518
  }
475
519
  });
476
520
 
@@ -1639,8 +1683,20 @@ var init_gateway = __esm({
1639
1683
  this.evidenceStore.save();
1640
1684
  }
1641
1685
  }
1642
- } else if (signed.warning) {
1643
- process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
1686
+ } else if (signed.error) {
1687
+ const tombstone = JSON.stringify({
1688
+ type: "scopeblind.signing_failure.v1",
1689
+ request_id: log.request_id,
1690
+ tool: log.tool,
1691
+ decision: log.decision,
1692
+ error: signed.error,
1693
+ at: new Date(log.timestamp).toISOString()
1694
+ });
1695
+ try {
1696
+ (0, import_node_fs6.appendFileSync)(this.receiptFilePath, tombstone + "\n");
1697
+ } catch {
1698
+ }
1699
+ process.stderr.write(`[PROTECT_MCP_SIGNING_FAILURE] ${tombstone}
1644
1700
  `);
1645
1701
  }
1646
1702
  }
@@ -4768,6 +4824,160 @@ var init_hook_patterns = __esm({
4768
4824
  }
4769
4825
  });
4770
4826
 
4827
+ // src/scopeblind-bridge.ts
4828
+ function getScopeBlindBridge() {
4829
+ if (!singleton) singleton = new ScopeBlindBridge();
4830
+ return singleton;
4831
+ }
4832
+ var DEFAULT_BASE, FLUSH_INTERVAL_MS, BATCH_MAX, BRASS_REFRESH_MARGIN_MS, ScopeBlindBridge, singleton;
4833
+ var init_scopeblind_bridge = __esm({
4834
+ "src/scopeblind-bridge.ts"() {
4835
+ "use strict";
4836
+ DEFAULT_BASE = "https://scopeblind.com";
4837
+ FLUSH_INTERVAL_MS = 5e3;
4838
+ BATCH_MAX = 128;
4839
+ BRASS_REFRESH_MARGIN_MS = 5 * 60 * 1e3;
4840
+ ScopeBlindBridge = class {
4841
+ token;
4842
+ base;
4843
+ tenantOverride;
4844
+ cachedProof = null;
4845
+ queue = [];
4846
+ flushTimer = null;
4847
+ stats;
4848
+ shuttingDown = false;
4849
+ constructor(env = process.env) {
4850
+ this.token = env.SCOPEBLIND_TOKEN || null;
4851
+ this.base = (env.SCOPEBLIND_BASE || DEFAULT_BASE).replace(/\/$/, "");
4852
+ this.tenantOverride = env.SCOPEBLIND_TENANT || null;
4853
+ this.stats = {
4854
+ enabled: Boolean(this.token),
4855
+ tenant_slug: this.tenantOverride,
4856
+ forwarded_total: 0,
4857
+ rejected_total: 0,
4858
+ last_flush_at: null,
4859
+ last_error: null
4860
+ };
4861
+ if (this.enabled()) {
4862
+ this.flushTimer = setInterval(() => {
4863
+ void this.flush();
4864
+ }, FLUSH_INTERVAL_MS);
4865
+ if (typeof this.flushTimer === "object" && this.flushTimer && "unref" in this.flushTimer) {
4866
+ this.flushTimer.unref?.();
4867
+ }
4868
+ process.on("beforeExit", () => {
4869
+ void this.shutdown();
4870
+ });
4871
+ }
4872
+ }
4873
+ enabled() {
4874
+ return Boolean(this.token);
4875
+ }
4876
+ /** Push a signed receipt into the queue. Non-blocking. */
4877
+ forward(signedReceipt) {
4878
+ if (!this.enabled() || this.shuttingDown) return;
4879
+ this.queue.push(signedReceipt);
4880
+ if (this.queue.length >= BATCH_MAX) void this.flush();
4881
+ }
4882
+ /** Flush the queue. Safe to call concurrently. */
4883
+ async flush() {
4884
+ if (!this.enabled() || this.queue.length === 0) return;
4885
+ const batch = this.queue.splice(0, BATCH_MAX);
4886
+ try {
4887
+ const proof = await this.ensureBrassProof();
4888
+ const slug = this.tenantOverride || proof?.tenant_id;
4889
+ if (!slug) {
4890
+ this.queue.unshift(...batch);
4891
+ return;
4892
+ }
4893
+ this.stats.tenant_slug = slug;
4894
+ const res = await fetch(`${this.base}/fn/console/${slug}/receipts`, {
4895
+ method: "POST",
4896
+ headers: {
4897
+ "content-type": "application/json",
4898
+ authorization: `Bearer ${this.token}`,
4899
+ "user-agent": "protect-mcp/scopeblind-bridge"
4900
+ },
4901
+ body: JSON.stringify({ receipts: batch })
4902
+ });
4903
+ if (!res.ok) {
4904
+ const errBody = await res.text().catch(() => "");
4905
+ this.stats.last_error = `HTTP ${res.status} ${errBody.slice(0, 160)}`;
4906
+ this.stats.rejected_total += batch.length;
4907
+ if (res.status >= 500 && res.status !== 503) {
4908
+ this.queue.unshift(...batch);
4909
+ }
4910
+ return;
4911
+ }
4912
+ const body = await res.json().catch(() => ({}));
4913
+ this.stats.forwarded_total += body?.accepted ?? batch.length;
4914
+ this.stats.rejected_total += body?.rejected ?? 0;
4915
+ this.stats.last_flush_at = (/* @__PURE__ */ new Date()).toISOString();
4916
+ this.stats.last_error = null;
4917
+ } catch (err) {
4918
+ this.stats.last_error = String(err?.message || err);
4919
+ this.queue.unshift(...batch);
4920
+ }
4921
+ }
4922
+ /** Exchange SCOPEBLIND_TOKEN for a BRASS-v2 proof; refresh near expiry. */
4923
+ async ensureBrassProof() {
4924
+ if (!this.token) return null;
4925
+ const now = Date.now();
4926
+ if (this.cachedProof && Date.parse(this.cachedProof.expires_at) - now > BRASS_REFRESH_MARGIN_MS) {
4927
+ return this.cachedProof;
4928
+ }
4929
+ try {
4930
+ const res = await fetch(`${this.base}/fn/brass/issue`, {
4931
+ method: "POST",
4932
+ headers: {
4933
+ "content-type": "application/json",
4934
+ "user-agent": "protect-mcp/scopeblind-bridge"
4935
+ },
4936
+ body: JSON.stringify({
4937
+ token: this.token,
4938
+ scope: "protect-mcp-receipt-emit",
4939
+ ttl_seconds: 3600
4940
+ })
4941
+ });
4942
+ if (!res.ok) {
4943
+ const text = await res.text().catch(() => "");
4944
+ this.stats.last_error = `brass-issue: HTTP ${res.status} ${text.slice(0, 160)}`;
4945
+ return null;
4946
+ }
4947
+ const body = await res.json();
4948
+ if (!body?.auth_proof) {
4949
+ this.stats.last_error = "brass-issue: missing auth_proof in response";
4950
+ return null;
4951
+ }
4952
+ this.cachedProof = body.auth_proof;
4953
+ return this.cachedProof;
4954
+ } catch (err) {
4955
+ this.stats.last_error = `brass-issue: ${err?.message || err}`;
4956
+ return null;
4957
+ }
4958
+ }
4959
+ /**
4960
+ * Return a snapshot of bridge stats. Useful for `protect-mcp scopeblind status`.
4961
+ */
4962
+ getStats() {
4963
+ return {
4964
+ ...this.stats,
4965
+ queued: this.queue.length,
4966
+ brass_proof_expires_at: this.cachedProof?.expires_at || null
4967
+ };
4968
+ }
4969
+ /** Flush remaining receipts and stop the interval. Called on process exit. */
4970
+ async shutdown() {
4971
+ if (this.shuttingDown) return;
4972
+ this.shuttingDown = true;
4973
+ if (this.flushTimer) clearInterval(this.flushTimer);
4974
+ if (this.queue.length > 0) await this.flush();
4975
+ }
4976
+ };
4977
+ singleton = null;
4978
+ }
4979
+ });
4980
+
4771
4981
  // src/hook-server.ts
4772
4982
  var hook_server_exports = {};
4773
4983
  __export(hook_server_exports, {
@@ -4976,7 +5186,7 @@ async function handlePreToolUse(input, state) {
4976
5186
  const hookLatency = Date.now() - hookStart;
4977
5187
  const denyKey = `${toolName}:${input.sessionId || "default"}`;
4978
5188
  state.denyCounter.delete(denyKey);
4979
- emitDecisionLog(state, {
5189
+ const emit = emitDecisionLog(state, {
4980
5190
  tool: toolName,
4981
5191
  decision: "allow",
4982
5192
  reason_code: state.cedarPolicies ? "cedar_allow" : state.jsonPolicy ? "policy_allow" : "observe_mode",
@@ -4988,6 +5198,15 @@ async function handlePreToolUse(input, state) {
4988
5198
  sandbox_state: detectSandboxState(),
4989
5199
  plan_receipt_id: state.activePlanReceiptId || void 0
4990
5200
  });
5201
+ if (state.enforce && emit.signingFailed) {
5202
+ return {
5203
+ hookSpecificOutput: {
5204
+ hookEventName: "PreToolUse",
5205
+ permissionDecision: "deny",
5206
+ permissionDecisionReason: `[ScopeBlind] "${toolName}" was blocked because its receipt could not be signed. Failing closed: a governed action that cannot be proven is not allowed.`
5207
+ }
5208
+ };
5209
+ }
4991
5210
  return {};
4992
5211
  }
4993
5212
  async function handlePostToolUse(input, state) {
@@ -5235,11 +5454,35 @@ function emitDecisionLog(state, entry) {
5235
5454
  } catch {
5236
5455
  }
5237
5456
  state.receiptBuffer.add(log.request_id, signed.signed);
5238
- } else if (signed.warning) {
5239
- process.stderr.write(`[PROTECT_MCP] Warning: ${signed.warning}
5457
+ try {
5458
+ const bridge = getScopeBlindBridge();
5459
+ if (bridge.enabled()) {
5460
+ const parsed = typeof signed.signed === "string" ? JSON.parse(signed.signed) : signed.signed;
5461
+ bridge.forward(parsed);
5462
+ }
5463
+ } catch (err) {
5464
+ process.stderr.write(`[PROTECT_MCP] ScopeBlind forward error: ${err instanceof Error ? err.message : err}
5465
+ `);
5466
+ }
5467
+ } else if (signed.error) {
5468
+ const tombstone = JSON.stringify({
5469
+ type: "scopeblind.signing_failure.v1",
5470
+ request_id: log.request_id,
5471
+ tool: log.tool,
5472
+ decision: log.decision,
5473
+ error: signed.error,
5474
+ at: new Date(log.timestamp).toISOString()
5475
+ });
5476
+ try {
5477
+ (0, import_node_fs8.appendFileSync)(state.receiptFilePath, tombstone + "\n");
5478
+ } catch {
5479
+ }
5480
+ process.stderr.write(`[PROTECT_MCP_SIGNING_FAILURE] ${tombstone}
5240
5481
  `);
5482
+ return { signingFailed: true };
5241
5483
  }
5242
5484
  }
5485
+ return { signingFailed: false };
5243
5486
  }
5244
5487
  async function routeHookEvent(input, state) {
5245
5488
  switch (input.hookEventName) {
@@ -5550,6 +5793,7 @@ var init_hook_server = __esm({
5550
5793
  init_signing();
5551
5794
  init_policy();
5552
5795
  init_http_server();
5796
+ init_scopeblind_bridge();
5553
5797
  DEFAULT_PORT = 9377;
5554
5798
  LOG_FILE3 = ".protect-mcp-log.jsonl";
5555
5799
  RECEIPTS_FILE2 = ".protect-mcp-receipts.jsonl";
@@ -7712,6 +7956,36 @@ main().catch((err) => {
7712
7956
  `);
7713
7957
  process.exit(1);
7714
7958
  });
7959
+ /**
7960
+ * scopeblind-bridge.ts
7961
+ *
7962
+ * Optional bridge between protect-mcp (local, MIT) and a paid ScopeBlind
7963
+ * tenant. When SCOPEBLIND_TOKEN is set in the environment, every signed
7964
+ * receipt that protect-mcp emits also gets forwarded to the tenant's
7965
+ * dashboard at https://scopeblind.com/console/<slug>.
7966
+ *
7967
+ * Lifecycle:
7968
+ * 1. On first use, exchange SCOPEBLIND_TOKEN for a short-lived BRASS-v2
7969
+ * auth proof from /fn/brass/issue. Cache the proof in memory until
7970
+ * ~5 minutes before expiry, then refresh.
7971
+ * 2. As receipts are emitted by hook-server.ts, push them into an
7972
+ * in-memory batch queue.
7973
+ * 3. Flush the queue every 5s (or when it reaches 128 receipts) by POSTing
7974
+ * to /fn/console/<slug>/receipts with Bearer SCOPEBLIND_TOKEN.
7975
+ *
7976
+ * Failure mode: forward errors NEVER throw upstream. protect-mcp continues
7977
+ * to mint and persist receipts locally regardless of dashboard availability.
7978
+ * The bridge logs failures to stderr (best-effort) and retries on the next
7979
+ * flush.
7980
+ *
7981
+ * Configuration:
7982
+ * SCOPEBLIND_TOKEN Tenant bearer token (from welcome email).
7983
+ * SCOPEBLIND_TENANT Optional slug override. By default we discover
7984
+ * the slug from the BRASS proof's tenant_id.
7985
+ * SCOPEBLIND_BASE Defaults to https://scopeblind.com.
7986
+ *
7987
+ * @license MIT
7988
+ */
7715
7989
  /*! Bundled license information:
7716
7990
 
7717
7991
  @noble/hashes/esm/utils.js:
package/dist/cli.mjs CHANGED
@@ -3,17 +3,17 @@ import {
3
3
  formatSimulation,
4
4
  parseLogFile,
5
5
  simulate
6
- } from "./chunk-GQWJCHQV.mjs";
6
+ } from "./chunk-S4ICHNSP.mjs";
7
7
  import {
8
8
  ProtectGateway,
9
9
  validateCredentials
10
- } from "./chunk-BYYWYSHM.mjs";
10
+ } from "./chunk-PLKRTBDR.mjs";
11
11
  import {
12
12
  initSigning,
13
13
  isCedarAvailable,
14
14
  loadCedarPolicies,
15
15
  loadPolicy
16
- } from "./chunk-YTBC72JJ.mjs";
16
+ } from "./chunk-UV53U6D4.mjs";
17
17
  import "./chunk-PQJP2ZCI.mjs";
18
18
 
19
19
  // src/cli.ts
@@ -1442,7 +1442,7 @@ async function main() {
1442
1442
  if (useHttp) {
1443
1443
  const portIdx = args.indexOf("--port");
1444
1444
  const httpPort = portIdx >= 0 && args[portIdx + 1] ? parseInt(args[portIdx + 1]) : 3e3;
1445
- const { startHttpTransport } = await import("./http-transport-LNBENGXD.mjs");
1445
+ const { startHttpTransport } = await import("./http-transport-MO32ESHZ.mjs");
1446
1446
  startHttpTransport({ port: httpPort, config, serverCommand: childCommand });
1447
1447
  return;
1448
1448
  }