@sanctuary-framework/mcp-server 0.10.0 → 0.10.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
@@ -2037,6 +2037,7 @@ var init_audit_log = __esm({
2037
2037
  maxTotalSizeBytes;
2038
2038
  maxEntries;
2039
2039
  rotationInFlight = false;
2040
+ pendingWrites = /* @__PURE__ */ new Set();
2040
2041
  constructor(storage, masterKey, config) {
2041
2042
  this.storage = storage;
2042
2043
  this.encryptionKey = derivePurposeKey(masterKey, "audit-log");
@@ -2045,6 +2046,15 @@ var init_audit_log = __esm({
2045
2046
  }
2046
2047
  /**
2047
2048
  * Append an audit entry.
2049
+ *
2050
+ * The on-disk persist is async and tracked via `pendingWrites`. Long-lived
2051
+ * callers (the main MCP server) can ignore that tracking and let writes
2052
+ * drain naturally. Short-lived callers — the `sanctuary secrets` CLI which
2053
+ * `process.exit()`s immediately after returning from a broker mutation —
2054
+ * MUST await `flush()` before exiting, or in-flight writes get killed
2055
+ * with the event loop and the entry is silently lost. That was the
2056
+ * v0.10.0-rc.2 soak failure mode where `secrets audit` returned empty
2057
+ * after a clean 7-verb lifecycle.
2048
2058
  */
2049
2059
  append(layer, operation, identityId, details, result = "success") {
2050
2060
  const entry = {
@@ -2056,8 +2066,22 @@ var init_audit_log = __esm({
2056
2066
  details
2057
2067
  };
2058
2068
  this.entries.push(entry);
2059
- this.persistEntry(entry).catch(() => {
2069
+ const writePromise = this.persistEntry(entry).catch(() => {
2060
2070
  });
2071
+ this.pendingWrites.add(writePromise);
2072
+ void writePromise.finally(() => this.pendingWrites.delete(writePromise));
2073
+ }
2074
+ /**
2075
+ * Wait for every in-flight `append()` persist (and its rotation pass) to
2076
+ * settle. Safe to call multiple times — newly-appended entries during a
2077
+ * flush are also awaited. Re-entrant only at the granularity of "drain
2078
+ * everything queued so far". Short-lived CLIs MUST call this before
2079
+ * `process.exit()` to keep audit writes durable.
2080
+ */
2081
+ async flush() {
2082
+ while (this.pendingWrites.size > 0) {
2083
+ await Promise.allSettled([...this.pendingWrites]);
2084
+ }
2061
2085
  }
2062
2086
  async persistEntry(entry) {
2063
2087
  const key = `${Date.now()}-${this.counter++}`;
@@ -2068,7 +2092,7 @@ var init_audit_log = __esm({
2068
2092
  key,
2069
2093
  stringToBytes(JSON.stringify(encrypted))
2070
2094
  );
2071
- this.maybeRotate().catch(() => {
2095
+ await this.maybeRotate().catch(() => {
2072
2096
  });
2073
2097
  }
2074
2098
  /**
@@ -8805,6 +8829,24 @@ var init_dashboard = __esm({
8805
8829
  rateLimits = /* @__PURE__ */ new Map();
8806
8830
  /** Whether the dashboard is running in standalone mode (no MCP server) */
8807
8831
  _standaloneMode = false;
8832
+ /**
8833
+ * v0.10.2: when set, requests from loopback addresses (127.0.0.1 / ::1)
8834
+ * are treated as authenticated without requiring a Bearer token or
8835
+ * dashboard session cookie. Only the `startStandaloneDashboard` boot
8836
+ * path enables this, and ONLY after the supplied passphrase successfully
8837
+ * decrypts at least one stored identity — proving the caller already
8838
+ * holds the primary secret that protects every piece of Sanctuary state.
8839
+ *
8840
+ * Rationale: the dashboard auth token is a dashboard-access credential
8841
+ * layered on top of the master-key unlock. Once the operator has already
8842
+ * presented the passphrase on the command line (terminal-side auth), a
8843
+ * second login prompt in the auto-opened browser just trains users to
8844
+ * paste secrets into web forms — the exact habit Sanctuary exists to
8845
+ * discourage. Remote (non-loopback) callers still require the bearer
8846
+ * token, so this is a localhost-only ergonomics unlock, not a network
8847
+ * policy change.
8848
+ */
8849
+ _autoAuthLocalhost = false;
8808
8850
  constructor(config) {
8809
8851
  this.config = config;
8810
8852
  this.authToken = config.auth_token;
@@ -8840,6 +8882,26 @@ var init_dashboard = __esm({
8840
8882
  setStandaloneMode(standalone) {
8841
8883
  this._standaloneMode = standalone;
8842
8884
  }
8885
+ /**
8886
+ * v0.10.2: enable (or disable) the loopback auto-auth fast path. See
8887
+ * {@link _autoAuthLocalhost} for the rationale and threat model. Callers
8888
+ * should gate this on both (a) the dashboard host being a loopback
8889
+ * interface and (b) the master-key unlock having succeeded against
8890
+ * on-disk state.
8891
+ */
8892
+ setAutoAuthLocalhost(enabled) {
8893
+ this._autoAuthLocalhost = enabled;
8894
+ }
8895
+ /**
8896
+ * v0.10.2: is this request from a loopback interface? We treat the
8897
+ * standard IPv4/IPv6 loopback addresses plus the IPv4-mapped IPv6 form
8898
+ * as loopback so LAN clients never accidentally hit the unauthenticated
8899
+ * fast path even on hosts where the HTTP server binds 0.0.0.0.
8900
+ */
8901
+ isLoopbackRequest(req) {
8902
+ const addr = this.getRemoteAddr(req);
8903
+ return addr === "127.0.0.1" || addr === "::1" || addr === "localhost";
8904
+ }
8843
8905
  /**
8844
8906
  * Start the HTTP(S) server for the dashboard.
8845
8907
  */
@@ -8989,6 +9051,9 @@ var init_dashboard = __esm({
8989
9051
  */
8990
9052
  checkAuth(req, url, res) {
8991
9053
  if (!this.authToken) return true;
9054
+ if (this._autoAuthLocalhost && this.isLoopbackRequest(req)) {
9055
+ return true;
9056
+ }
8992
9057
  const authHeader = req.headers.authorization;
8993
9058
  if (authHeader) {
8994
9059
  const parts = authHeader.split(" ");
@@ -9014,6 +9079,9 @@ var init_dashboard = __esm({
9014
9079
  */
9015
9080
  isAuthenticated(req, url) {
9016
9081
  if (!this.authToken) return true;
9082
+ if (this._autoAuthLocalhost && this.isLoopbackRequest(req)) {
9083
+ return true;
9084
+ }
9017
9085
  const authHeader = req.headers.authorization;
9018
9086
  if (authHeader) {
9019
9087
  const parts = authHeader.split(" ");
@@ -24128,11 +24196,13 @@ var init_runtime = __esm({
24128
24196
  var cli_exports = {};
24129
24197
  __export(cli_exports, {
24130
24198
  COCOON_GOVERNOR_DEFAULTS: () => COCOON_GOVERNOR_DEFAULTS,
24199
+ PORT_FALLBACK_ATTEMPTS: () => PORT_FALLBACK_ATTEMPTS,
24131
24200
  formatWrapSuccess: () => formatWrapSuccess,
24132
24201
  parseCocoonArgs: () => parseCocoonArgs,
24133
24202
  parseWrapArgs: () => parseWrapArgs,
24134
24203
  runCocoon: () => runCocoon,
24135
- runWrap: () => runWrap
24204
+ runWrap: () => runWrap,
24205
+ startDashboardWithFallback: () => startDashboardWithFallback
24136
24206
  });
24137
24207
  async function runWrap(options, deps = {}) {
24138
24208
  if (options.unwrap) {
@@ -24376,7 +24446,8 @@ async function runCocoon(options) {
24376
24446
  }
24377
24447
  async function startDashboardWithFallback(startFn, preferredPort, authToken, serverVersion) {
24378
24448
  let lastErr;
24379
- for (let port = preferredPort; port <= MAX_PORT; port++) {
24449
+ for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
24450
+ const port = preferredPort + i;
24380
24451
  try {
24381
24452
  const handle = await startFn({
24382
24453
  port,
@@ -24395,8 +24466,9 @@ async function startDashboardWithFallback(startFn, preferredPort, authToken, ser
24395
24466
  if (!isAddressInUse(err)) throw err;
24396
24467
  }
24397
24468
  }
24469
+ const lastPort = preferredPort + PORT_FALLBACK_ATTEMPTS - 1;
24398
24470
  throw new Error(
24399
- `No free dashboard port in range ${preferredPort}-${MAX_PORT}: ${lastErr?.message ?? "unknown"}`
24471
+ `No free dashboard port in the ${PORT_FALLBACK_ATTEMPTS} ports starting at ${preferredPort} (tried ${preferredPort}-${lastPort}): ${lastErr?.message ?? "unknown"}`
24400
24472
  );
24401
24473
  }
24402
24474
  function isAddressInUse(err) {
@@ -24644,7 +24716,7 @@ function printWrapHelp() {
24644
24716
  5. Every tool call is logged, scanned, and tier-gated
24645
24717
  `);
24646
24718
  }
24647
- var COCOON_GOVERNOR_DEFAULTS, MAX_PORT, parseCocoonArgs;
24719
+ var COCOON_GOVERNOR_DEFAULTS, PORT_FALLBACK_ATTEMPTS, parseCocoonArgs;
24648
24720
  var init_cli = __esm({
24649
24721
  "src/cocoon/cli.ts"() {
24650
24722
  init_config_reader();
@@ -24658,7 +24730,7 @@ var init_cli = __esm({
24658
24730
  rate_limit_per_tool: 20,
24659
24731
  lifetime_limit: 1e3
24660
24732
  };
24661
- MAX_PORT = 3510;
24733
+ PORT_FALLBACK_ATTEMPTS = 20;
24662
24734
  parseCocoonArgs = parseWrapArgs;
24663
24735
  }
24664
24736
  });
@@ -26162,6 +26234,7 @@ async function openBroker(opts = {}) {
26162
26234
  return {
26163
26235
  broker,
26164
26236
  close: async () => {
26237
+ await auditLog.flush();
26165
26238
  }
26166
26239
  };
26167
26240
  }
@@ -27186,12 +27259,12 @@ function optionalNumber(args, key) {
27186
27259
  const v = args[key];
27187
27260
  return typeof v === "number" && Number.isFinite(v) ? v : void 0;
27188
27261
  }
27189
- var require4, PKG_VERSION3;
27262
+ var PKG_VERSION3;
27190
27263
  var init_broker_server = __esm({
27191
27264
  "src/mcp/broker-server.ts"() {
27265
+ init_config();
27192
27266
  init_token_issuer();
27193
- require4 = createRequire(import.meta.url);
27194
- ({ version: PKG_VERSION3 } = require4("../../package.json"));
27267
+ PKG_VERSION3 = SANCTUARY_VERSION;
27195
27268
  }
27196
27269
  });
27197
27270
 
@@ -27355,7 +27428,28 @@ async function startStandaloneDashboard(options = {}) {
27355
27428
  await mkdir(config.storage_path, { recursive: true, mode: 448 });
27356
27429
  const storage = new FilesystemStorage(`${config.storage_path}/state`);
27357
27430
  let masterKey;
27358
- const passphrase = options.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
27431
+ let passphrase = options.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
27432
+ let passphraseSource = null;
27433
+ if (passphrase) {
27434
+ passphraseSource = options.passphrase !== void 0 ? "option" : "env";
27435
+ } else {
27436
+ try {
27437
+ const stored = await readStoredPassphrase({
27438
+ storagePath: config.storage_path
27439
+ });
27440
+ if (stored) {
27441
+ passphrase = stored.value;
27442
+ passphraseSource = stored.source === "keychain" ? "keychain" : "fallback-file";
27443
+ console.error(
27444
+ `Passphrase: loaded from ${stored.location} (service ${keychainServiceFor(config.storage_path, homedir())})`
27445
+ );
27446
+ }
27447
+ } catch (err) {
27448
+ if (err instanceof PassphraseUnreadableError) {
27449
+ throw err;
27450
+ }
27451
+ }
27452
+ }
27359
27453
  if (passphrase) {
27360
27454
  let existingParams;
27361
27455
  try {
@@ -27368,6 +27462,14 @@ async function startStandaloneDashboard(options = {}) {
27368
27462
  }
27369
27463
  const result = await deriveMasterKey(passphrase, existingParams);
27370
27464
  masterKey = result.key;
27465
+ if (!existingParams) {
27466
+ const { stringToBytes: stringToBytes2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
27467
+ await storage.write(
27468
+ "_meta",
27469
+ "key-params",
27470
+ stringToBytes2(JSON.stringify(result.params))
27471
+ );
27472
+ }
27371
27473
  } else {
27372
27474
  const { hashToString: hashToString2 } = await Promise.resolve().then(() => (init_hashing(), hashing_exports));
27373
27475
  const { stringToBytes: stringToBytes2, bytesToString: bytesToString2, fromBase64url: fromBase64url2, constantTimeEqual: constantTimeEqual2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
@@ -27466,6 +27568,10 @@ async function startStandaloneDashboard(options = {}) {
27466
27568
  profileStore
27467
27569
  });
27468
27570
  dashboard.setStandaloneMode(true);
27571
+ const hostIsLoopback = dashboardHost === "127.0.0.1" || dashboardHost === "::1" || dashboardHost === "localhost";
27572
+ if (hostIsLoopback && loadResult.loaded > 0) {
27573
+ dashboard.setAutoAuthLocalhost(true);
27574
+ }
27469
27575
  await dashboard.start();
27470
27576
  await writeTenantRuntime(config.storage_path, {
27471
27577
  version: SANCTUARY_VERSION,
@@ -27491,21 +27597,29 @@ async function startStandaloneDashboard(options = {}) {
27491
27597
  console.error(`Identities loaded: ${loadResult.loaded}`);
27492
27598
  console.error(`Listening: http://${dashboardHost}:${dashboardPort}`);
27493
27599
  if (loadResult.total > 0 && loadResult.loaded === 0) {
27600
+ const service = keychainServiceFor(config.storage_path, homedir());
27601
+ const sourceLabel = passphraseSource === "option" ? "--passphrase option" : passphraseSource === "env" ? "SANCTUARY_PASSPHRASE env var" : passphraseSource === "keychain" ? `macOS Keychain (service ${service})` : passphraseSource === "fallback-file" ? "encrypted fallback file" : "recovery key";
27494
27602
  console.error(
27495
27603
  `
27496
- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
27497
- \u2551 \u26A0 WARNING: Encrypted identities found but NONE loaded \u2551
27498
- \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
27499
- \u2551 ${loadResult.total} encrypted identity file(s) found on disk \u2551
27500
- \u2551 0 could be decrypted with the current master key \u2551
27501
- \u2551 \u2551
27502
- \u2551 This usually means SANCTUARY_PASSPHRASE is missing or \u2551
27503
- \u2551 incorrect. The dashboard will show empty panels. \u2551
27504
- \u2551 \u2551
27505
- \u2551 To fix: restart with the correct SANCTUARY_PASSPHRASE: \u2551
27506
- \u2551 SANCTUARY_PASSPHRASE=<your-passphrase> npx \\ \u2551
27507
- \u2551 @sanctuary-framework/mcp-server dashboard \u2551
27508
- \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
27604
+ \u26A0 WARNING: Encrypted identities found but NONE loaded
27605
+ ${loadResult.total} encrypted identity file(s) in ${config.storage_path}/state/_identities/
27606
+ 0 could be decrypted with the master key derived from the ${sourceLabel}.
27607
+
27608
+ The dashboard will show empty panels. Since v0.10.0 each wrapped
27609
+ tenant has its OWN passphrase, stored under a per-tenant Keychain
27610
+ service name. A single SANCTUARY_PASSPHRASE unlocks at most one
27611
+ tenant \u2014 it is not a global master credential.
27612
+
27613
+ To diagnose:
27614
+ \u2022 List tenants on this host: sanctuary agents
27615
+ \u2022 Multi-tenant overview (no decrypt): sanctuary dashboard --multi
27616
+ \u2022 Point at a specific tenant: SANCTUARY_STORAGE_PATH=<path> sanctuary dashboard
27617
+ \u2022 Inspect this tenant's Keychain: security find-generic-password -s '${service}' -w
27618
+
27619
+ If this tenant's passphrase lives only in a different Keychain item
27620
+ or on another machine, restore it before this dashboard can read
27621
+ any state. Sanctuary will never auto-regenerate \u2014 that would
27622
+ permanently destroy the data encrypted under the prior key.
27509
27623
  `
27510
27624
  );
27511
27625
  } else if (loadResult.failed > 0) {
@@ -27535,6 +27649,7 @@ var init_dashboard_standalone = __esm({
27535
27649
  init_tools();
27536
27650
  init_sovereignty_profile();
27537
27651
  init_runtime();
27652
+ init_passphrase();
27538
27653
  }
27539
27654
  });
27540
27655
 
@@ -27610,8 +27725,8 @@ async function checkForUpdate(currentVersion) {
27610
27725
  } catch {
27611
27726
  }
27612
27727
  }
27613
- var require5 = createRequire(import.meta.url);
27614
- var { version: PKG_VERSION4 } = require5("../package.json");
27728
+ var require4 = createRequire(import.meta.url);
27729
+ var { version: PKG_VERSION4 } = require4("../package.json");
27615
27730
  async function main() {
27616
27731
  const args = process.argv.slice(2);
27617
27732
  let passphrase = process.env.SANCTUARY_PASSPHRASE;