@sanctuary-framework/mcp-server 0.5.0 → 0.5.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/index.cjs CHANGED
@@ -257,7 +257,18 @@ function defaultConfig() {
257
257
  };
258
258
  }
259
259
  async function loadConfig(configPath) {
260
- const config = defaultConfig();
260
+ let config = defaultConfig();
261
+ const storagePath = process.env.SANCTUARY_STORAGE_PATH ?? config.storage_path;
262
+ const path$1 = configPath ?? path.join(storagePath, "sanctuary.json");
263
+ try {
264
+ const raw = await promises.readFile(path$1, "utf-8");
265
+ const fileConfig = JSON.parse(raw);
266
+ config = deepMerge(config, fileConfig);
267
+ } catch (err) {
268
+ if (err instanceof Error && err.message.includes("unimplemented features")) {
269
+ throw err;
270
+ }
271
+ }
261
272
  if (process.env.SANCTUARY_STORAGE_PATH) {
262
273
  config.storage_path = process.env.SANCTUARY_STORAGE_PATH;
263
274
  }
@@ -270,6 +281,9 @@ async function loadConfig(configPath) {
270
281
  if (process.env.SANCTUARY_DASHBOARD_ENABLED === "true") {
271
282
  config.dashboard.enabled = true;
272
283
  }
284
+ if (process.env.SANCTUARY_DASHBOARD_ENABLED === "false") {
285
+ config.dashboard.enabled = false;
286
+ }
273
287
  if (process.env.SANCTUARY_DASHBOARD_PORT) {
274
288
  config.dashboard.port = parseInt(process.env.SANCTUARY_DASHBOARD_PORT, 10);
275
289
  }
@@ -288,6 +302,9 @@ async function loadConfig(configPath) {
288
302
  if (process.env.SANCTUARY_WEBHOOK_ENABLED === "true") {
289
303
  config.webhook.enabled = true;
290
304
  }
305
+ if (process.env.SANCTUARY_WEBHOOK_ENABLED === "false") {
306
+ config.webhook.enabled = false;
307
+ }
291
308
  if (process.env.SANCTUARY_WEBHOOK_URL) {
292
309
  config.webhook.url = process.env.SANCTUARY_WEBHOOK_URL;
293
310
  }
@@ -300,19 +317,9 @@ async function loadConfig(configPath) {
300
317
  if (process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST) {
301
318
  config.webhook.callback_host = process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST;
302
319
  }
303
- const path$1 = configPath ?? path.join(config.storage_path, "sanctuary.json");
304
- try {
305
- const raw = await promises.readFile(path$1, "utf-8");
306
- const fileConfig = JSON.parse(raw);
307
- const merged = deepMerge(config, fileConfig);
308
- validateConfig(merged);
309
- return merged;
310
- } catch (err) {
311
- if (err instanceof Error && err.message.includes("unimplemented features")) {
312
- throw err;
313
- }
314
- return config;
315
- }
320
+ config.version = PKG_VERSION;
321
+ validateConfig(config);
322
+ return config;
316
323
  }
317
324
  async function saveConfig(config, configPath) {
318
325
  const path$1 = path.join(config.storage_path, "sanctuary.json");
@@ -4051,6 +4058,181 @@ var AutoApproveChannel = class {
4051
4058
  };
4052
4059
 
4053
4060
  // src/principal-policy/dashboard-html.ts
4061
+ function generateLoginHTML(options) {
4062
+ return `<!DOCTYPE html>
4063
+ <html lang="en">
4064
+ <head>
4065
+ <meta charset="utf-8">
4066
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4067
+ <title>Sanctuary \u2014 Login</title>
4068
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
4069
+ <style>
4070
+ :root {
4071
+ --bg: #0d1117;
4072
+ --surface: #161b22;
4073
+ --border: #30363d;
4074
+ --text-primary: #e6edf3;
4075
+ --text-secondary: #8b949e;
4076
+ --green: #3fb950;
4077
+ --red: #f85149;
4078
+ --blue: #58a6ff;
4079
+ --mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
4080
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
4081
+ --radius: 6px;
4082
+ }
4083
+ * { box-sizing: border-box; margin: 0; padding: 0; }
4084
+ html, body { width: 100%; height: 100%; }
4085
+ body {
4086
+ font-family: var(--sans);
4087
+ background: var(--bg);
4088
+ color: var(--text-primary);
4089
+ display: flex;
4090
+ align-items: center;
4091
+ justify-content: center;
4092
+ }
4093
+ .login-container {
4094
+ width: 100%;
4095
+ max-width: 400px;
4096
+ padding: 40px 32px;
4097
+ background: var(--surface);
4098
+ border: 1px solid var(--border);
4099
+ border-radius: 12px;
4100
+ }
4101
+ .login-logo {
4102
+ text-align: center;
4103
+ font-size: 20px;
4104
+ font-weight: 700;
4105
+ letter-spacing: -0.5px;
4106
+ margin-bottom: 8px;
4107
+ }
4108
+ .login-logo span { color: var(--blue); }
4109
+ .login-version {
4110
+ text-align: center;
4111
+ font-size: 11px;
4112
+ color: var(--text-secondary);
4113
+ font-family: var(--mono);
4114
+ margin-bottom: 32px;
4115
+ }
4116
+ .login-label {
4117
+ display: block;
4118
+ font-size: 13px;
4119
+ font-weight: 600;
4120
+ color: var(--text-secondary);
4121
+ margin-bottom: 8px;
4122
+ }
4123
+ .login-input {
4124
+ width: 100%;
4125
+ padding: 10px 14px;
4126
+ background: var(--bg);
4127
+ border: 1px solid var(--border);
4128
+ border-radius: var(--radius);
4129
+ color: var(--text-primary);
4130
+ font-family: var(--mono);
4131
+ font-size: 14px;
4132
+ outline: none;
4133
+ transition: border-color 0.15s;
4134
+ }
4135
+ .login-input:focus { border-color: var(--blue); }
4136
+ .login-input::placeholder { color: var(--text-secondary); opacity: 0.5; }
4137
+ .login-btn {
4138
+ width: 100%;
4139
+ margin-top: 20px;
4140
+ padding: 10px;
4141
+ background: var(--blue);
4142
+ color: var(--bg);
4143
+ border: none;
4144
+ border-radius: var(--radius);
4145
+ font-size: 14px;
4146
+ font-weight: 600;
4147
+ cursor: pointer;
4148
+ transition: opacity 0.15s;
4149
+ font-family: var(--sans);
4150
+ }
4151
+ .login-btn:hover { opacity: 0.9; }
4152
+ .login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4153
+ .login-error {
4154
+ margin-top: 16px;
4155
+ padding: 10px 14px;
4156
+ background: rgba(248, 81, 73, 0.1);
4157
+ border: 1px solid var(--red);
4158
+ border-radius: var(--radius);
4159
+ font-size: 12px;
4160
+ color: var(--red);
4161
+ display: none;
4162
+ }
4163
+ .login-hint {
4164
+ margin-top: 24px;
4165
+ padding-top: 16px;
4166
+ border-top: 1px solid var(--border);
4167
+ font-size: 11px;
4168
+ color: var(--text-secondary);
4169
+ line-height: 1.5;
4170
+ }
4171
+ .login-hint code {
4172
+ font-family: var(--mono);
4173
+ background: var(--bg);
4174
+ padding: 1px 4px;
4175
+ border-radius: 3px;
4176
+ font-size: 10px;
4177
+ }
4178
+ </style>
4179
+ </head>
4180
+ <body>
4181
+ <div class="login-container">
4182
+ <div class="login-logo"><span>&#9670;</span> SANCTUARY</div>
4183
+ <div class="login-version">Principal Dashboard v${options.serverVersion}</div>
4184
+ <form id="loginForm" onsubmit="return handleLogin(event)">
4185
+ <label class="login-label" for="tokenInput">Dashboard Auth Token</label>
4186
+ <input class="login-input" type="password" id="tokenInput"
4187
+ placeholder="Enter your auth token" autocomplete="off" autofocus required>
4188
+ <button class="login-btn" type="submit" id="loginBtn">Open Dashboard</button>
4189
+ </form>
4190
+ <div class="login-error" id="loginError"></div>
4191
+ <div class="login-hint">
4192
+ Your token is set via <code>SANCTUARY_DASHBOARD_AUTH_TOKEN</code> environment variable,
4193
+ or check your server's startup output.
4194
+ </div>
4195
+ </div>
4196
+ <script>
4197
+ async function handleLogin(e) {
4198
+ e.preventDefault();
4199
+ var btn = document.getElementById('loginBtn');
4200
+ var errEl = document.getElementById('loginError');
4201
+ var token = document.getElementById('tokenInput').value.trim();
4202
+ if (!token) return false;
4203
+ btn.disabled = true;
4204
+ btn.textContent = 'Authenticating...';
4205
+ errEl.style.display = 'none';
4206
+ try {
4207
+ var resp = await fetch('/auth/session', {
4208
+ method: 'POST',
4209
+ headers: { 'Authorization': 'Bearer ' + token }
4210
+ });
4211
+ if (!resp.ok) {
4212
+ var data = await resp.json().catch(function() { return {}; });
4213
+ throw new Error(data.error || 'Authentication failed');
4214
+ }
4215
+ var result = await resp.json();
4216
+ // Store token in sessionStorage for auto-renewal inside the dashboard
4217
+ try { sessionStorage.setItem('sanctuary_token', token); } catch(_) {}
4218
+ // Set session cookie
4219
+ var maxAge = result.expires_in_seconds || 300;
4220
+ document.cookie = 'sanctuary_session=' + result.session_id +
4221
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
4222
+ // Reload to enter the dashboard
4223
+ window.location.reload();
4224
+ } catch (err) {
4225
+ errEl.textContent = err.message || 'Authentication failed. Check your token.';
4226
+ errEl.style.display = 'block';
4227
+ btn.disabled = false;
4228
+ btn.textContent = 'Open Dashboard';
4229
+ }
4230
+ return false;
4231
+ }
4232
+ </script>
4233
+ </body>
4234
+ </html>`;
4235
+ }
4054
4236
  function generateDashboardHTML(options) {
4055
4237
  return `<!DOCTYPE html>
4056
4238
  <html lang="en">
@@ -4920,7 +5102,9 @@ function generateDashboardHTML(options) {
4920
5102
  // \u2500\u2500 Configuration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4921
5103
 
4922
5104
  const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4923
- const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5105
+ // AUTH_TOKEN: embedded token (for direct session access) or from sessionStorage (login page flow)
5106
+ const EMBEDDED_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5107
+ const AUTH_TOKEN = EMBEDDED_TOKEN || (function() { try { return sessionStorage.getItem('sanctuary_token'); } catch(_) { return null; } })();
4924
5108
  const MAX_ACTIVITY_ITEMS = 100;
4925
5109
  const MAX_THREAT_ITEMS = 20;
4926
5110
 
@@ -4935,6 +5119,7 @@ function generateDashboardHTML(options) {
4935
5119
  const activityItems = [];
4936
5120
  const threatItems = [];
4937
5121
  let sovereigntyScore = 85;
5122
+ let sessionRenewalTimer = null;
4938
5123
 
4939
5124
  // \u2500\u2500 Auth Helpers (SEC-012) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4940
5125
 
@@ -4950,6 +5135,11 @@ function generateDashboardHTML(options) {
4950
5135
  return url + sep + 'session=' + SESSION_ID;
4951
5136
  }
4952
5137
 
5138
+ function setCookie(sessionId, maxAge) {
5139
+ document.cookie = 'sanctuary_session=' + sessionId +
5140
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
5141
+ }
5142
+
4953
5143
  async function exchangeSession() {
4954
5144
  if (!AUTH_TOKEN) return;
4955
5145
  try {
@@ -4957,14 +5147,35 @@ function generateDashboardHTML(options) {
4957
5147
  if (resp.ok) {
4958
5148
  const data = await resp.json();
4959
5149
  SESSION_ID = data.session_id;
4960
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4961
- setTimeout(() => { exchangeSession(); reconnectSSE(); }, refreshMs);
5150
+ var ttl = data.expires_in_seconds || 300;
5151
+ // Update cookie with new session
5152
+ setCookie(SESSION_ID, ttl);
5153
+ // Schedule renewal at 80% of TTL
5154
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5155
+ sessionRenewalTimer = setTimeout(function() {
5156
+ exchangeSession().then(function() { reconnectSSE(); });
5157
+ }, ttl * 800);
5158
+ } else if (resp.status === 401) {
5159
+ // Token invalid or expired \u2014 show non-destructive re-login overlay
5160
+ showSessionExpired();
4962
5161
  }
4963
5162
  } catch (e) {
4964
- // Retry on next connect
5163
+ // Network error \u2014 retry in 30s
5164
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5165
+ sessionRenewalTimer = setTimeout(function() {
5166
+ exchangeSession().then(function() { reconnectSSE(); });
5167
+ }, 30000);
4965
5168
  }
4966
5169
  }
4967
5170
 
5171
+ function showSessionExpired() {
5172
+ // Clear stored token
5173
+ try { sessionStorage.removeItem('sanctuary_token'); } catch(_) {}
5174
+ // Redirect to login page
5175
+ document.cookie = 'sanctuary_session=; path=/; max-age=0';
5176
+ window.location.reload();
5177
+ }
5178
+
4968
5179
  // \u2500\u2500 UI Utilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4969
5180
 
4970
5181
  function esc(s) {
@@ -5437,7 +5648,8 @@ function generateDashboardHTML(options) {
5437
5648
  }
5438
5649
 
5439
5650
  // src/principal-policy/dashboard.ts
5440
- var SESSION_TTL_MS = 5 * 60 * 1e3;
5651
+ var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
5652
+ var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
5441
5653
  var MAX_SESSIONS = 1e3;
5442
5654
  var RATE_LIMIT_WINDOW_MS = 6e4;
5443
5655
  var RATE_LIMIT_GENERAL = 120;
@@ -5452,8 +5664,11 @@ var DashboardApprovalChannel = class {
5452
5664
  baseline = null;
5453
5665
  auditLog = null;
5454
5666
  dashboardHTML;
5667
+ loginHTML;
5455
5668
  authToken;
5456
5669
  useTLS;
5670
+ /** Session TTL: longer for localhost, shorter for remote */
5671
+ sessionTTLMs;
5457
5672
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5458
5673
  sessions = /* @__PURE__ */ new Map();
5459
5674
  sessionCleanupTimer = null;
@@ -5463,11 +5678,14 @@ var DashboardApprovalChannel = class {
5463
5678
  this.config = config;
5464
5679
  this.authToken = config.auth_token;
5465
5680
  this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
5681
+ const isLocalhost = config.host === "127.0.0.1" || config.host === "localhost" || config.host === "::1";
5682
+ this.sessionTTLMs = isLocalhost ? SESSION_TTL_LOCAL_MS : SESSION_TTL_REMOTE_MS;
5466
5683
  this.dashboardHTML = generateDashboardHTML({
5467
5684
  timeoutSeconds: config.timeout_seconds,
5468
5685
  serverVersion: SANCTUARY_VERSION,
5469
5686
  authToken: this.authToken
5470
5687
  });
5688
+ this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
5471
5689
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5472
5690
  }
5473
5691
  /**
@@ -5497,25 +5715,26 @@ var DashboardApprovalChannel = class {
5497
5715
  const protocol = this.useTLS ? "https" : "http";
5498
5716
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5499
5717
  this.httpServer.listen(this.config.port, this.config.host, () => {
5500
- if (this.authToken) {
5501
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5502
- process.stderr.write(
5503
- `
5718
+ process.stderr.write(
5719
+ `
5504
5720
  Sanctuary Principal Dashboard: ${baseUrl}
5505
5721
  `
5506
- );
5722
+ );
5723
+ if (this.authToken) {
5724
+ const sessionUrl = this.createSessionUrl();
5507
5725
  process.stderr.write(
5508
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5509
-
5726
+ ` Quick open: ${sessionUrl}
5510
5727
  `
5511
5728
  );
5512
- } else {
5729
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5513
5730
  process.stderr.write(
5514
- `
5515
- Sanctuary Principal Dashboard: ${baseUrl}
5731
+ ` Auth token: ${hint}
5516
5732
 
5517
5733
  `
5518
5734
  );
5735
+ } else {
5736
+ process.stderr.write(`
5737
+ `);
5519
5738
  }
5520
5739
  resolve();
5521
5740
  });
@@ -5619,10 +5838,47 @@ var DashboardApprovalChannel = class {
5619
5838
  if (sessionId && this.validateSession(sessionId)) {
5620
5839
  return true;
5621
5840
  }
5841
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5842
+ if (cookieSession && this.validateSession(cookieSession)) {
5843
+ return true;
5844
+ }
5622
5845
  res.writeHead(401, { "Content-Type": "application/json" });
5623
5846
  res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
5624
5847
  return false;
5625
5848
  }
5849
+ /**
5850
+ * Check if a request is authenticated WITHOUT sending a response.
5851
+ * Used to decide between login page vs dashboard for GET /.
5852
+ */
5853
+ isAuthenticated(req, url) {
5854
+ if (!this.authToken) return true;
5855
+ const authHeader = req.headers.authorization;
5856
+ if (authHeader) {
5857
+ const parts = authHeader.split(" ");
5858
+ if (parts.length === 2 && parts[0] === "Bearer" && parts[1] === this.authToken) {
5859
+ return true;
5860
+ }
5861
+ }
5862
+ const sessionId = url.searchParams.get("session");
5863
+ if (sessionId && this.validateSession(sessionId)) return true;
5864
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5865
+ if (cookieSession && this.validateSession(cookieSession)) return true;
5866
+ return false;
5867
+ }
5868
+ /**
5869
+ * Parse a specific cookie value from the request.
5870
+ */
5871
+ parseCookie(req, name) {
5872
+ const header = req.headers.cookie;
5873
+ if (!header) return null;
5874
+ for (const part of header.split(";")) {
5875
+ const [key, ...rest] = part.split("=");
5876
+ if (key?.trim() === name) {
5877
+ return rest.join("=").trim();
5878
+ }
5879
+ }
5880
+ return null;
5881
+ }
5626
5882
  // ── Session Management (SEC-012) ──────────────────────────────────
5627
5883
  /**
5628
5884
  * Create a short-lived session by exchanging the long-lived auth token
@@ -5643,7 +5899,7 @@ var DashboardApprovalChannel = class {
5643
5899
  this.sessions.set(id, {
5644
5900
  id,
5645
5901
  created_at: now,
5646
- expires_at: now + SESSION_TTL_MS
5902
+ expires_at: now + this.sessionTTLMs
5647
5903
  });
5648
5904
  return id;
5649
5905
  }
@@ -5742,13 +5998,26 @@ var DashboardApprovalChannel = class {
5742
5998
  res.end();
5743
5999
  return;
5744
6000
  }
5745
- if (!this.checkAuth(req, url, res)) return;
5746
- if (!this.checkRateLimit(req, res, "general")) return;
5747
- try {
5748
- if (method === "POST" && url.pathname === "/auth/session") {
6001
+ if (method === "POST" && url.pathname === "/auth/session") {
6002
+ if (!this.checkRateLimit(req, res, "general")) return;
6003
+ try {
5749
6004
  this.handleSessionExchange(req, res);
6005
+ } catch {
6006
+ res.writeHead(500, { "Content-Type": "application/json" });
6007
+ res.end(JSON.stringify({ error: "Internal server error" }));
6008
+ }
6009
+ return;
6010
+ }
6011
+ if (method === "GET" && url.pathname === "/" && this.authToken) {
6012
+ if (!this.isAuthenticated(req, url)) {
6013
+ if (!this.checkRateLimit(req, res, "general")) return;
6014
+ this.serveLoginPage(res);
5750
6015
  return;
5751
6016
  }
6017
+ }
6018
+ if (!this.checkAuth(req, url, res)) return;
6019
+ if (!this.checkRateLimit(req, res, "general")) return;
6020
+ try {
5752
6021
  if (method === "GET" && url.pathname === "/") {
5753
6022
  this.serveDashboard(res);
5754
6023
  } else if (method === "GET" && url.pathname === "/events") {
@@ -5805,12 +6074,23 @@ var DashboardApprovalChannel = class {
5805
6074
  return;
5806
6075
  }
5807
6076
  const sessionId = this.createSession();
5808
- res.writeHead(200, { "Content-Type": "application/json" });
6077
+ const ttlSeconds = Math.floor(this.sessionTTLMs / 1e3);
6078
+ res.writeHead(200, {
6079
+ "Content-Type": "application/json",
6080
+ "Set-Cookie": `sanctuary_session=${sessionId}; Path=/; SameSite=Strict; Max-Age=${ttlSeconds}`
6081
+ });
5809
6082
  res.end(JSON.stringify({
5810
6083
  session_id: sessionId,
5811
- expires_in_seconds: SESSION_TTL_MS / 1e3
6084
+ expires_in_seconds: ttlSeconds
5812
6085
  }));
5813
6086
  }
6087
+ serveLoginPage(res) {
6088
+ res.writeHead(200, {
6089
+ "Content-Type": "text/html; charset=utf-8",
6090
+ "Cache-Control": "no-cache, no-store"
6091
+ });
6092
+ res.end(this.loginHTML);
6093
+ }
5814
6094
  serveDashboard(res) {
5815
6095
  res.writeHead(200, {
5816
6096
  "Content-Type": "text/html; charset=utf-8",
@@ -5986,6 +6266,22 @@ data: ${JSON.stringify(data)}
5986
6266
  broadcastProtectionStatus(data) {
5987
6267
  this.broadcastSSE("protection-status", data);
5988
6268
  }
6269
+ /**
6270
+ * Create a pre-authenticated URL for the dashboard.
6271
+ * Used by the sanctuary_dashboard_open tool and at startup.
6272
+ */
6273
+ createSessionUrl() {
6274
+ const sessionId = this.createSession();
6275
+ const protocol = this.useTLS ? "https" : "http";
6276
+ return `${protocol}://${this.config.host}:${this.config.port}/?session=${sessionId}`;
6277
+ }
6278
+ /**
6279
+ * Get the base URL for the dashboard.
6280
+ */
6281
+ getBaseUrl() {
6282
+ const protocol = this.useTLS ? "https" : "http";
6283
+ return `${protocol}://${this.config.host}:${this.config.port}`;
6284
+ }
5989
6285
  /** Get the number of pending requests */
5990
6286
  get pendingCount() {
5991
6287
  return this.pending.size;
@@ -11855,6 +12151,32 @@ async function createSanctuaryServer(options) {
11855
12151
  } : void 0;
11856
12152
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
11857
12153
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
12154
+ const dashboardTools = [];
12155
+ if (dashboard) {
12156
+ dashboardTools.push({
12157
+ name: "sanctuary/dashboard_open",
12158
+ description: "Generate a one-click URL to open the Principal Dashboard in a browser. Returns a pre-authenticated link \u2014 no manual token entry needed.",
12159
+ inputSchema: {
12160
+ type: "object",
12161
+ properties: {}
12162
+ },
12163
+ handler: async () => {
12164
+ const url = dashboard.createSessionUrl();
12165
+ return {
12166
+ content: [
12167
+ {
12168
+ type: "text",
12169
+ text: JSON.stringify({
12170
+ dashboard_url: url,
12171
+ base_url: dashboard.getBaseUrl(),
12172
+ note: "Click the dashboard_url to open the Principal Dashboard. The session is pre-authenticated."
12173
+ }, null, 2)
12174
+ }
12175
+ ]
12176
+ };
12177
+ }
12178
+ });
12179
+ }
11858
12180
  let allTools = [
11859
12181
  ...l1Tools,
11860
12182
  ...l2Tools,
@@ -11868,6 +12190,7 @@ async function createSanctuaryServer(options) {
11868
12190
  ...auditTools,
11869
12191
  ...contextGateTools,
11870
12192
  ...hardeningTools,
12193
+ ...dashboardTools,
11871
12194
  manifestTool
11872
12195
  ];
11873
12196
  allTools = allTools.map((tool) => ({