@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.cjs CHANGED
@@ -2040,6 +2040,7 @@ var init_audit_log = __esm({
2040
2040
  maxTotalSizeBytes;
2041
2041
  maxEntries;
2042
2042
  rotationInFlight = false;
2043
+ pendingWrites = /* @__PURE__ */ new Set();
2043
2044
  constructor(storage, masterKey, config) {
2044
2045
  this.storage = storage;
2045
2046
  this.encryptionKey = derivePurposeKey(masterKey, "audit-log");
@@ -2048,6 +2049,15 @@ var init_audit_log = __esm({
2048
2049
  }
2049
2050
  /**
2050
2051
  * Append an audit entry.
2052
+ *
2053
+ * The on-disk persist is async and tracked via `pendingWrites`. Long-lived
2054
+ * callers (the main MCP server) can ignore that tracking and let writes
2055
+ * drain naturally. Short-lived callers — the `sanctuary secrets` CLI which
2056
+ * `process.exit()`s immediately after returning from a broker mutation —
2057
+ * MUST await `flush()` before exiting, or in-flight writes get killed
2058
+ * with the event loop and the entry is silently lost. That was the
2059
+ * v0.10.0-rc.2 soak failure mode where `secrets audit` returned empty
2060
+ * after a clean 7-verb lifecycle.
2051
2061
  */
2052
2062
  append(layer, operation, identityId, details, result = "success") {
2053
2063
  const entry = {
@@ -2059,8 +2069,22 @@ var init_audit_log = __esm({
2059
2069
  details
2060
2070
  };
2061
2071
  this.entries.push(entry);
2062
- this.persistEntry(entry).catch(() => {
2072
+ const writePromise = this.persistEntry(entry).catch(() => {
2063
2073
  });
2074
+ this.pendingWrites.add(writePromise);
2075
+ void writePromise.finally(() => this.pendingWrites.delete(writePromise));
2076
+ }
2077
+ /**
2078
+ * Wait for every in-flight `append()` persist (and its rotation pass) to
2079
+ * settle. Safe to call multiple times — newly-appended entries during a
2080
+ * flush are also awaited. Re-entrant only at the granularity of "drain
2081
+ * everything queued so far". Short-lived CLIs MUST call this before
2082
+ * `process.exit()` to keep audit writes durable.
2083
+ */
2084
+ async flush() {
2085
+ while (this.pendingWrites.size > 0) {
2086
+ await Promise.allSettled([...this.pendingWrites]);
2087
+ }
2064
2088
  }
2065
2089
  async persistEntry(entry) {
2066
2090
  const key = `${Date.now()}-${this.counter++}`;
@@ -2071,7 +2095,7 @@ var init_audit_log = __esm({
2071
2095
  key,
2072
2096
  stringToBytes(JSON.stringify(encrypted))
2073
2097
  );
2074
- this.maybeRotate().catch(() => {
2098
+ await this.maybeRotate().catch(() => {
2075
2099
  });
2076
2100
  }
2077
2101
  /**
@@ -8808,6 +8832,24 @@ var init_dashboard = __esm({
8808
8832
  rateLimits = /* @__PURE__ */ new Map();
8809
8833
  /** Whether the dashboard is running in standalone mode (no MCP server) */
8810
8834
  _standaloneMode = false;
8835
+ /**
8836
+ * v0.10.2: when set, requests from loopback addresses (127.0.0.1 / ::1)
8837
+ * are treated as authenticated without requiring a Bearer token or
8838
+ * dashboard session cookie. Only the `startStandaloneDashboard` boot
8839
+ * path enables this, and ONLY after the supplied passphrase successfully
8840
+ * decrypts at least one stored identity — proving the caller already
8841
+ * holds the primary secret that protects every piece of Sanctuary state.
8842
+ *
8843
+ * Rationale: the dashboard auth token is a dashboard-access credential
8844
+ * layered on top of the master-key unlock. Once the operator has already
8845
+ * presented the passphrase on the command line (terminal-side auth), a
8846
+ * second login prompt in the auto-opened browser just trains users to
8847
+ * paste secrets into web forms — the exact habit Sanctuary exists to
8848
+ * discourage. Remote (non-loopback) callers still require the bearer
8849
+ * token, so this is a localhost-only ergonomics unlock, not a network
8850
+ * policy change.
8851
+ */
8852
+ _autoAuthLocalhost = false;
8811
8853
  constructor(config) {
8812
8854
  this.config = config;
8813
8855
  this.authToken = config.auth_token;
@@ -8843,6 +8885,26 @@ var init_dashboard = __esm({
8843
8885
  setStandaloneMode(standalone) {
8844
8886
  this._standaloneMode = standalone;
8845
8887
  }
8888
+ /**
8889
+ * v0.10.2: enable (or disable) the loopback auto-auth fast path. See
8890
+ * {@link _autoAuthLocalhost} for the rationale and threat model. Callers
8891
+ * should gate this on both (a) the dashboard host being a loopback
8892
+ * interface and (b) the master-key unlock having succeeded against
8893
+ * on-disk state.
8894
+ */
8895
+ setAutoAuthLocalhost(enabled) {
8896
+ this._autoAuthLocalhost = enabled;
8897
+ }
8898
+ /**
8899
+ * v0.10.2: is this request from a loopback interface? We treat the
8900
+ * standard IPv4/IPv6 loopback addresses plus the IPv4-mapped IPv6 form
8901
+ * as loopback so LAN clients never accidentally hit the unauthenticated
8902
+ * fast path even on hosts where the HTTP server binds 0.0.0.0.
8903
+ */
8904
+ isLoopbackRequest(req) {
8905
+ const addr = this.getRemoteAddr(req);
8906
+ return addr === "127.0.0.1" || addr === "::1" || addr === "localhost";
8907
+ }
8846
8908
  /**
8847
8909
  * Start the HTTP(S) server for the dashboard.
8848
8910
  */
@@ -8992,6 +9054,9 @@ var init_dashboard = __esm({
8992
9054
  */
8993
9055
  checkAuth(req, url, res) {
8994
9056
  if (!this.authToken) return true;
9057
+ if (this._autoAuthLocalhost && this.isLoopbackRequest(req)) {
9058
+ return true;
9059
+ }
8995
9060
  const authHeader = req.headers.authorization;
8996
9061
  if (authHeader) {
8997
9062
  const parts = authHeader.split(" ");
@@ -9017,6 +9082,9 @@ var init_dashboard = __esm({
9017
9082
  */
9018
9083
  isAuthenticated(req, url) {
9019
9084
  if (!this.authToken) return true;
9085
+ if (this._autoAuthLocalhost && this.isLoopbackRequest(req)) {
9086
+ return true;
9087
+ }
9020
9088
  const authHeader = req.headers.authorization;
9021
9089
  if (authHeader) {
9022
9090
  const parts = authHeader.split(" ");
@@ -24131,11 +24199,13 @@ var init_runtime = __esm({
24131
24199
  var cli_exports = {};
24132
24200
  __export(cli_exports, {
24133
24201
  COCOON_GOVERNOR_DEFAULTS: () => COCOON_GOVERNOR_DEFAULTS,
24202
+ PORT_FALLBACK_ATTEMPTS: () => PORT_FALLBACK_ATTEMPTS,
24134
24203
  formatWrapSuccess: () => formatWrapSuccess,
24135
24204
  parseCocoonArgs: () => parseCocoonArgs,
24136
24205
  parseWrapArgs: () => parseWrapArgs,
24137
24206
  runCocoon: () => runCocoon,
24138
- runWrap: () => runWrap
24207
+ runWrap: () => runWrap,
24208
+ startDashboardWithFallback: () => startDashboardWithFallback
24139
24209
  });
24140
24210
  async function runWrap(options, deps = {}) {
24141
24211
  if (options.unwrap) {
@@ -24379,7 +24449,8 @@ async function runCocoon(options) {
24379
24449
  }
24380
24450
  async function startDashboardWithFallback(startFn, preferredPort, authToken, serverVersion) {
24381
24451
  let lastErr;
24382
- for (let port = preferredPort; port <= MAX_PORT; port++) {
24452
+ for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
24453
+ const port = preferredPort + i;
24383
24454
  try {
24384
24455
  const handle = await startFn({
24385
24456
  port,
@@ -24398,8 +24469,9 @@ async function startDashboardWithFallback(startFn, preferredPort, authToken, ser
24398
24469
  if (!isAddressInUse(err)) throw err;
24399
24470
  }
24400
24471
  }
24472
+ const lastPort = preferredPort + PORT_FALLBACK_ATTEMPTS - 1;
24401
24473
  throw new Error(
24402
- `No free dashboard port in range ${preferredPort}-${MAX_PORT}: ${lastErr?.message ?? "unknown"}`
24474
+ `No free dashboard port in the ${PORT_FALLBACK_ATTEMPTS} ports starting at ${preferredPort} (tried ${preferredPort}-${lastPort}): ${lastErr?.message ?? "unknown"}`
24403
24475
  );
24404
24476
  }
24405
24477
  function isAddressInUse(err) {
@@ -24647,7 +24719,7 @@ function printWrapHelp() {
24647
24719
  5. Every tool call is logged, scanned, and tier-gated
24648
24720
  `);
24649
24721
  }
24650
- var COCOON_GOVERNOR_DEFAULTS, MAX_PORT, parseCocoonArgs;
24722
+ var COCOON_GOVERNOR_DEFAULTS, PORT_FALLBACK_ATTEMPTS, parseCocoonArgs;
24651
24723
  var init_cli = __esm({
24652
24724
  "src/cocoon/cli.ts"() {
24653
24725
  init_config_reader();
@@ -24661,7 +24733,7 @@ var init_cli = __esm({
24661
24733
  rate_limit_per_tool: 20,
24662
24734
  lifetime_limit: 1e3
24663
24735
  };
24664
- MAX_PORT = 3510;
24736
+ PORT_FALLBACK_ATTEMPTS = 20;
24665
24737
  parseCocoonArgs = parseWrapArgs;
24666
24738
  }
24667
24739
  });
@@ -26165,6 +26237,7 @@ async function openBroker(opts = {}) {
26165
26237
  return {
26166
26238
  broker,
26167
26239
  close: async () => {
26240
+ await auditLog.flush();
26168
26241
  }
26169
26242
  };
26170
26243
  }
@@ -27189,12 +27262,12 @@ function optionalNumber(args, key) {
27189
27262
  const v = args[key];
27190
27263
  return typeof v === "number" && Number.isFinite(v) ? v : void 0;
27191
27264
  }
27192
- var require4, PKG_VERSION3;
27265
+ var PKG_VERSION3;
27193
27266
  var init_broker_server = __esm({
27194
27267
  "src/mcp/broker-server.ts"() {
27268
+ init_config();
27195
27269
  init_token_issuer();
27196
- require4 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href)));
27197
- ({ version: PKG_VERSION3 } = require4("../../package.json"));
27270
+ PKG_VERSION3 = SANCTUARY_VERSION;
27198
27271
  }
27199
27272
  });
27200
27273
 
@@ -27358,7 +27431,28 @@ async function startStandaloneDashboard(options = {}) {
27358
27431
  await promises.mkdir(config.storage_path, { recursive: true, mode: 448 });
27359
27432
  const storage = new FilesystemStorage(`${config.storage_path}/state`);
27360
27433
  let masterKey;
27361
- const passphrase = options.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
27434
+ let passphrase = options.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
27435
+ let passphraseSource = null;
27436
+ if (passphrase) {
27437
+ passphraseSource = options.passphrase !== void 0 ? "option" : "env";
27438
+ } else {
27439
+ try {
27440
+ const stored = await readStoredPassphrase({
27441
+ storagePath: config.storage_path
27442
+ });
27443
+ if (stored) {
27444
+ passphrase = stored.value;
27445
+ passphraseSource = stored.source === "keychain" ? "keychain" : "fallback-file";
27446
+ console.error(
27447
+ `Passphrase: loaded from ${stored.location} (service ${keychainServiceFor(config.storage_path, os.homedir())})`
27448
+ );
27449
+ }
27450
+ } catch (err) {
27451
+ if (err instanceof PassphraseUnreadableError) {
27452
+ throw err;
27453
+ }
27454
+ }
27455
+ }
27362
27456
  if (passphrase) {
27363
27457
  let existingParams;
27364
27458
  try {
@@ -27371,6 +27465,14 @@ async function startStandaloneDashboard(options = {}) {
27371
27465
  }
27372
27466
  const result = await deriveMasterKey(passphrase, existingParams);
27373
27467
  masterKey = result.key;
27468
+ if (!existingParams) {
27469
+ const { stringToBytes: stringToBytes2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
27470
+ await storage.write(
27471
+ "_meta",
27472
+ "key-params",
27473
+ stringToBytes2(JSON.stringify(result.params))
27474
+ );
27475
+ }
27374
27476
  } else {
27375
27477
  const { hashToString: hashToString2 } = await Promise.resolve().then(() => (init_hashing(), hashing_exports));
27376
27478
  const { stringToBytes: stringToBytes2, bytesToString: bytesToString2, fromBase64url: fromBase64url2, constantTimeEqual: constantTimeEqual2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
@@ -27469,6 +27571,10 @@ async function startStandaloneDashboard(options = {}) {
27469
27571
  profileStore
27470
27572
  });
27471
27573
  dashboard.setStandaloneMode(true);
27574
+ const hostIsLoopback = dashboardHost === "127.0.0.1" || dashboardHost === "::1" || dashboardHost === "localhost";
27575
+ if (hostIsLoopback && loadResult.loaded > 0) {
27576
+ dashboard.setAutoAuthLocalhost(true);
27577
+ }
27472
27578
  await dashboard.start();
27473
27579
  await writeTenantRuntime(config.storage_path, {
27474
27580
  version: SANCTUARY_VERSION,
@@ -27494,21 +27600,29 @@ async function startStandaloneDashboard(options = {}) {
27494
27600
  console.error(`Identities loaded: ${loadResult.loaded}`);
27495
27601
  console.error(`Listening: http://${dashboardHost}:${dashboardPort}`);
27496
27602
  if (loadResult.total > 0 && loadResult.loaded === 0) {
27603
+ const service = keychainServiceFor(config.storage_path, os.homedir());
27604
+ 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";
27497
27605
  console.error(
27498
27606
  `
27499
- \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
27500
- \u2551 \u26A0 WARNING: Encrypted identities found but NONE loaded \u2551
27501
- \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
27502
- \u2551 ${loadResult.total} encrypted identity file(s) found on disk \u2551
27503
- \u2551 0 could be decrypted with the current master key \u2551
27504
- \u2551 \u2551
27505
- \u2551 This usually means SANCTUARY_PASSPHRASE is missing or \u2551
27506
- \u2551 incorrect. The dashboard will show empty panels. \u2551
27507
- \u2551 \u2551
27508
- \u2551 To fix: restart with the correct SANCTUARY_PASSPHRASE: \u2551
27509
- \u2551 SANCTUARY_PASSPHRASE=<your-passphrase> npx \\ \u2551
27510
- \u2551 @sanctuary-framework/mcp-server dashboard \u2551
27511
- \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
27607
+ \u26A0 WARNING: Encrypted identities found but NONE loaded
27608
+ ${loadResult.total} encrypted identity file(s) in ${config.storage_path}/state/_identities/
27609
+ 0 could be decrypted with the master key derived from the ${sourceLabel}.
27610
+
27611
+ The dashboard will show empty panels. Since v0.10.0 each wrapped
27612
+ tenant has its OWN passphrase, stored under a per-tenant Keychain
27613
+ service name. A single SANCTUARY_PASSPHRASE unlocks at most one
27614
+ tenant \u2014 it is not a global master credential.
27615
+
27616
+ To diagnose:
27617
+ \u2022 List tenants on this host: sanctuary agents
27618
+ \u2022 Multi-tenant overview (no decrypt): sanctuary dashboard --multi
27619
+ \u2022 Point at a specific tenant: SANCTUARY_STORAGE_PATH=<path> sanctuary dashboard
27620
+ \u2022 Inspect this tenant's Keychain: security find-generic-password -s '${service}' -w
27621
+
27622
+ If this tenant's passphrase lives only in a different Keychain item
27623
+ or on another machine, restore it before this dashboard can read
27624
+ any state. Sanctuary will never auto-regenerate \u2014 that would
27625
+ permanently destroy the data encrypted under the prior key.
27512
27626
  `
27513
27627
  );
27514
27628
  } else if (loadResult.failed > 0) {
@@ -27538,6 +27652,7 @@ var init_dashboard_standalone = __esm({
27538
27652
  init_tools();
27539
27653
  init_sovereignty_profile();
27540
27654
  init_runtime();
27655
+ init_passphrase();
27541
27656
  }
27542
27657
  });
27543
27658
 
@@ -27613,8 +27728,8 @@ async function checkForUpdate(currentVersion) {
27613
27728
  } catch {
27614
27729
  }
27615
27730
  }
27616
- var require5 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href)));
27617
- var { version: PKG_VERSION4 } = require5("../package.json");
27731
+ var require4 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href)));
27732
+ var { version: PKG_VERSION4 } = require4("../package.json");
27618
27733
  async function main() {
27619
27734
  const args = process.argv.slice(2);
27620
27735
  let passphrase = process.env.SANCTUARY_PASSPHRASE;