@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/cli.cjs CHANGED
@@ -259,7 +259,18 @@ function defaultConfig() {
259
259
  };
260
260
  }
261
261
  async function loadConfig(configPath) {
262
- const config = defaultConfig();
262
+ let config = defaultConfig();
263
+ const storagePath = process.env.SANCTUARY_STORAGE_PATH ?? config.storage_path;
264
+ const path$1 = configPath ?? path.join(storagePath, "sanctuary.json");
265
+ try {
266
+ const raw = await promises.readFile(path$1, "utf-8");
267
+ const fileConfig = JSON.parse(raw);
268
+ config = deepMerge(config, fileConfig);
269
+ } catch (err) {
270
+ if (err instanceof Error && err.message.includes("unimplemented features")) {
271
+ throw err;
272
+ }
273
+ }
263
274
  if (process.env.SANCTUARY_STORAGE_PATH) {
264
275
  config.storage_path = process.env.SANCTUARY_STORAGE_PATH;
265
276
  }
@@ -272,6 +283,9 @@ async function loadConfig(configPath) {
272
283
  if (process.env.SANCTUARY_DASHBOARD_ENABLED === "true") {
273
284
  config.dashboard.enabled = true;
274
285
  }
286
+ if (process.env.SANCTUARY_DASHBOARD_ENABLED === "false") {
287
+ config.dashboard.enabled = false;
288
+ }
275
289
  if (process.env.SANCTUARY_DASHBOARD_PORT) {
276
290
  config.dashboard.port = parseInt(process.env.SANCTUARY_DASHBOARD_PORT, 10);
277
291
  }
@@ -290,6 +304,9 @@ async function loadConfig(configPath) {
290
304
  if (process.env.SANCTUARY_WEBHOOK_ENABLED === "true") {
291
305
  config.webhook.enabled = true;
292
306
  }
307
+ if (process.env.SANCTUARY_WEBHOOK_ENABLED === "false") {
308
+ config.webhook.enabled = false;
309
+ }
293
310
  if (process.env.SANCTUARY_WEBHOOK_URL) {
294
311
  config.webhook.url = process.env.SANCTUARY_WEBHOOK_URL;
295
312
  }
@@ -302,19 +319,9 @@ async function loadConfig(configPath) {
302
319
  if (process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST) {
303
320
  config.webhook.callback_host = process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST;
304
321
  }
305
- const path$1 = configPath ?? path.join(config.storage_path, "sanctuary.json");
306
- try {
307
- const raw = await promises.readFile(path$1, "utf-8");
308
- const fileConfig = JSON.parse(raw);
309
- const merged = deepMerge(config, fileConfig);
310
- validateConfig(merged);
311
- return merged;
312
- } catch (err) {
313
- if (err instanceof Error && err.message.includes("unimplemented features")) {
314
- throw err;
315
- }
316
- return config;
317
- }
322
+ config.version = PKG_VERSION;
323
+ validateConfig(config);
324
+ return config;
318
325
  }
319
326
  async function saveConfig(config, configPath) {
320
327
  const path$1 = path.join(config.storage_path, "sanctuary.json");
@@ -4035,6 +4042,181 @@ var StderrApprovalChannel = class {
4035
4042
  };
4036
4043
 
4037
4044
  // src/principal-policy/dashboard-html.ts
4045
+ function generateLoginHTML(options) {
4046
+ return `<!DOCTYPE html>
4047
+ <html lang="en">
4048
+ <head>
4049
+ <meta charset="utf-8">
4050
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4051
+ <title>Sanctuary \u2014 Login</title>
4052
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
4053
+ <style>
4054
+ :root {
4055
+ --bg: #0d1117;
4056
+ --surface: #161b22;
4057
+ --border: #30363d;
4058
+ --text-primary: #e6edf3;
4059
+ --text-secondary: #8b949e;
4060
+ --green: #3fb950;
4061
+ --red: #f85149;
4062
+ --blue: #58a6ff;
4063
+ --mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
4064
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
4065
+ --radius: 6px;
4066
+ }
4067
+ * { box-sizing: border-box; margin: 0; padding: 0; }
4068
+ html, body { width: 100%; height: 100%; }
4069
+ body {
4070
+ font-family: var(--sans);
4071
+ background: var(--bg);
4072
+ color: var(--text-primary);
4073
+ display: flex;
4074
+ align-items: center;
4075
+ justify-content: center;
4076
+ }
4077
+ .login-container {
4078
+ width: 100%;
4079
+ max-width: 400px;
4080
+ padding: 40px 32px;
4081
+ background: var(--surface);
4082
+ border: 1px solid var(--border);
4083
+ border-radius: 12px;
4084
+ }
4085
+ .login-logo {
4086
+ text-align: center;
4087
+ font-size: 20px;
4088
+ font-weight: 700;
4089
+ letter-spacing: -0.5px;
4090
+ margin-bottom: 8px;
4091
+ }
4092
+ .login-logo span { color: var(--blue); }
4093
+ .login-version {
4094
+ text-align: center;
4095
+ font-size: 11px;
4096
+ color: var(--text-secondary);
4097
+ font-family: var(--mono);
4098
+ margin-bottom: 32px;
4099
+ }
4100
+ .login-label {
4101
+ display: block;
4102
+ font-size: 13px;
4103
+ font-weight: 600;
4104
+ color: var(--text-secondary);
4105
+ margin-bottom: 8px;
4106
+ }
4107
+ .login-input {
4108
+ width: 100%;
4109
+ padding: 10px 14px;
4110
+ background: var(--bg);
4111
+ border: 1px solid var(--border);
4112
+ border-radius: var(--radius);
4113
+ color: var(--text-primary);
4114
+ font-family: var(--mono);
4115
+ font-size: 14px;
4116
+ outline: none;
4117
+ transition: border-color 0.15s;
4118
+ }
4119
+ .login-input:focus { border-color: var(--blue); }
4120
+ .login-input::placeholder { color: var(--text-secondary); opacity: 0.5; }
4121
+ .login-btn {
4122
+ width: 100%;
4123
+ margin-top: 20px;
4124
+ padding: 10px;
4125
+ background: var(--blue);
4126
+ color: var(--bg);
4127
+ border: none;
4128
+ border-radius: var(--radius);
4129
+ font-size: 14px;
4130
+ font-weight: 600;
4131
+ cursor: pointer;
4132
+ transition: opacity 0.15s;
4133
+ font-family: var(--sans);
4134
+ }
4135
+ .login-btn:hover { opacity: 0.9; }
4136
+ .login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4137
+ .login-error {
4138
+ margin-top: 16px;
4139
+ padding: 10px 14px;
4140
+ background: rgba(248, 81, 73, 0.1);
4141
+ border: 1px solid var(--red);
4142
+ border-radius: var(--radius);
4143
+ font-size: 12px;
4144
+ color: var(--red);
4145
+ display: none;
4146
+ }
4147
+ .login-hint {
4148
+ margin-top: 24px;
4149
+ padding-top: 16px;
4150
+ border-top: 1px solid var(--border);
4151
+ font-size: 11px;
4152
+ color: var(--text-secondary);
4153
+ line-height: 1.5;
4154
+ }
4155
+ .login-hint code {
4156
+ font-family: var(--mono);
4157
+ background: var(--bg);
4158
+ padding: 1px 4px;
4159
+ border-radius: 3px;
4160
+ font-size: 10px;
4161
+ }
4162
+ </style>
4163
+ </head>
4164
+ <body>
4165
+ <div class="login-container">
4166
+ <div class="login-logo"><span>&#9670;</span> SANCTUARY</div>
4167
+ <div class="login-version">Principal Dashboard v${options.serverVersion}</div>
4168
+ <form id="loginForm" onsubmit="return handleLogin(event)">
4169
+ <label class="login-label" for="tokenInput">Dashboard Auth Token</label>
4170
+ <input class="login-input" type="password" id="tokenInput"
4171
+ placeholder="Enter your auth token" autocomplete="off" autofocus required>
4172
+ <button class="login-btn" type="submit" id="loginBtn">Open Dashboard</button>
4173
+ </form>
4174
+ <div class="login-error" id="loginError"></div>
4175
+ <div class="login-hint">
4176
+ Your token is set via <code>SANCTUARY_DASHBOARD_AUTH_TOKEN</code> environment variable,
4177
+ or check your server's startup output.
4178
+ </div>
4179
+ </div>
4180
+ <script>
4181
+ async function handleLogin(e) {
4182
+ e.preventDefault();
4183
+ var btn = document.getElementById('loginBtn');
4184
+ var errEl = document.getElementById('loginError');
4185
+ var token = document.getElementById('tokenInput').value.trim();
4186
+ if (!token) return false;
4187
+ btn.disabled = true;
4188
+ btn.textContent = 'Authenticating...';
4189
+ errEl.style.display = 'none';
4190
+ try {
4191
+ var resp = await fetch('/auth/session', {
4192
+ method: 'POST',
4193
+ headers: { 'Authorization': 'Bearer ' + token }
4194
+ });
4195
+ if (!resp.ok) {
4196
+ var data = await resp.json().catch(function() { return {}; });
4197
+ throw new Error(data.error || 'Authentication failed');
4198
+ }
4199
+ var result = await resp.json();
4200
+ // Store token in sessionStorage for auto-renewal inside the dashboard
4201
+ try { sessionStorage.setItem('sanctuary_token', token); } catch(_) {}
4202
+ // Set session cookie
4203
+ var maxAge = result.expires_in_seconds || 300;
4204
+ document.cookie = 'sanctuary_session=' + result.session_id +
4205
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
4206
+ // Reload to enter the dashboard
4207
+ window.location.reload();
4208
+ } catch (err) {
4209
+ errEl.textContent = err.message || 'Authentication failed. Check your token.';
4210
+ errEl.style.display = 'block';
4211
+ btn.disabled = false;
4212
+ btn.textContent = 'Open Dashboard';
4213
+ }
4214
+ return false;
4215
+ }
4216
+ </script>
4217
+ </body>
4218
+ </html>`;
4219
+ }
4038
4220
  function generateDashboardHTML(options) {
4039
4221
  return `<!DOCTYPE html>
4040
4222
  <html lang="en">
@@ -4904,7 +5086,9 @@ function generateDashboardHTML(options) {
4904
5086
  // \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
4905
5087
 
4906
5088
  const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4907
- const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5089
+ // AUTH_TOKEN: embedded token (for direct session access) or from sessionStorage (login page flow)
5090
+ const EMBEDDED_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5091
+ const AUTH_TOKEN = EMBEDDED_TOKEN || (function() { try { return sessionStorage.getItem('sanctuary_token'); } catch(_) { return null; } })();
4908
5092
  const MAX_ACTIVITY_ITEMS = 100;
4909
5093
  const MAX_THREAT_ITEMS = 20;
4910
5094
 
@@ -4919,6 +5103,7 @@ function generateDashboardHTML(options) {
4919
5103
  const activityItems = [];
4920
5104
  const threatItems = [];
4921
5105
  let sovereigntyScore = 85;
5106
+ let sessionRenewalTimer = null;
4922
5107
 
4923
5108
  // \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
4924
5109
 
@@ -4934,6 +5119,11 @@ function generateDashboardHTML(options) {
4934
5119
  return url + sep + 'session=' + SESSION_ID;
4935
5120
  }
4936
5121
 
5122
+ function setCookie(sessionId, maxAge) {
5123
+ document.cookie = 'sanctuary_session=' + sessionId +
5124
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
5125
+ }
5126
+
4937
5127
  async function exchangeSession() {
4938
5128
  if (!AUTH_TOKEN) return;
4939
5129
  try {
@@ -4941,14 +5131,35 @@ function generateDashboardHTML(options) {
4941
5131
  if (resp.ok) {
4942
5132
  const data = await resp.json();
4943
5133
  SESSION_ID = data.session_id;
4944
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4945
- setTimeout(() => { exchangeSession(); reconnectSSE(); }, refreshMs);
5134
+ var ttl = data.expires_in_seconds || 300;
5135
+ // Update cookie with new session
5136
+ setCookie(SESSION_ID, ttl);
5137
+ // Schedule renewal at 80% of TTL
5138
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5139
+ sessionRenewalTimer = setTimeout(function() {
5140
+ exchangeSession().then(function() { reconnectSSE(); });
5141
+ }, ttl * 800);
5142
+ } else if (resp.status === 401) {
5143
+ // Token invalid or expired \u2014 show non-destructive re-login overlay
5144
+ showSessionExpired();
4946
5145
  }
4947
5146
  } catch (e) {
4948
- // Retry on next connect
5147
+ // Network error \u2014 retry in 30s
5148
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5149
+ sessionRenewalTimer = setTimeout(function() {
5150
+ exchangeSession().then(function() { reconnectSSE(); });
5151
+ }, 30000);
4949
5152
  }
4950
5153
  }
4951
5154
 
5155
+ function showSessionExpired() {
5156
+ // Clear stored token
5157
+ try { sessionStorage.removeItem('sanctuary_token'); } catch(_) {}
5158
+ // Redirect to login page
5159
+ document.cookie = 'sanctuary_session=; path=/; max-age=0';
5160
+ window.location.reload();
5161
+ }
5162
+
4952
5163
  // \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
4953
5164
 
4954
5165
  function esc(s) {
@@ -5421,7 +5632,8 @@ function generateDashboardHTML(options) {
5421
5632
  }
5422
5633
 
5423
5634
  // src/principal-policy/dashboard.ts
5424
- var SESSION_TTL_MS = 5 * 60 * 1e3;
5635
+ var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
5636
+ var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
5425
5637
  var MAX_SESSIONS = 1e3;
5426
5638
  var RATE_LIMIT_WINDOW_MS = 6e4;
5427
5639
  var RATE_LIMIT_GENERAL = 120;
@@ -5436,8 +5648,11 @@ var DashboardApprovalChannel = class {
5436
5648
  baseline = null;
5437
5649
  auditLog = null;
5438
5650
  dashboardHTML;
5651
+ loginHTML;
5439
5652
  authToken;
5440
5653
  useTLS;
5654
+ /** Session TTL: longer for localhost, shorter for remote */
5655
+ sessionTTLMs;
5441
5656
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5442
5657
  sessions = /* @__PURE__ */ new Map();
5443
5658
  sessionCleanupTimer = null;
@@ -5447,11 +5662,14 @@ var DashboardApprovalChannel = class {
5447
5662
  this.config = config;
5448
5663
  this.authToken = config.auth_token;
5449
5664
  this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
5665
+ const isLocalhost = config.host === "127.0.0.1" || config.host === "localhost" || config.host === "::1";
5666
+ this.sessionTTLMs = isLocalhost ? SESSION_TTL_LOCAL_MS : SESSION_TTL_REMOTE_MS;
5450
5667
  this.dashboardHTML = generateDashboardHTML({
5451
5668
  timeoutSeconds: config.timeout_seconds,
5452
5669
  serverVersion: SANCTUARY_VERSION,
5453
5670
  authToken: this.authToken
5454
5671
  });
5672
+ this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
5455
5673
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5456
5674
  }
5457
5675
  /**
@@ -5481,25 +5699,26 @@ var DashboardApprovalChannel = class {
5481
5699
  const protocol = this.useTLS ? "https" : "http";
5482
5700
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5483
5701
  this.httpServer.listen(this.config.port, this.config.host, () => {
5484
- if (this.authToken) {
5485
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5486
- process.stderr.write(
5487
- `
5702
+ process.stderr.write(
5703
+ `
5488
5704
  Sanctuary Principal Dashboard: ${baseUrl}
5489
5705
  `
5490
- );
5706
+ );
5707
+ if (this.authToken) {
5708
+ const sessionUrl = this.createSessionUrl();
5491
5709
  process.stderr.write(
5492
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5493
-
5710
+ ` Quick open: ${sessionUrl}
5494
5711
  `
5495
5712
  );
5496
- } else {
5713
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5497
5714
  process.stderr.write(
5498
- `
5499
- Sanctuary Principal Dashboard: ${baseUrl}
5715
+ ` Auth token: ${hint}
5500
5716
 
5501
5717
  `
5502
5718
  );
5719
+ } else {
5720
+ process.stderr.write(`
5721
+ `);
5503
5722
  }
5504
5723
  resolve();
5505
5724
  });
@@ -5603,10 +5822,47 @@ var DashboardApprovalChannel = class {
5603
5822
  if (sessionId && this.validateSession(sessionId)) {
5604
5823
  return true;
5605
5824
  }
5825
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5826
+ if (cookieSession && this.validateSession(cookieSession)) {
5827
+ return true;
5828
+ }
5606
5829
  res.writeHead(401, { "Content-Type": "application/json" });
5607
5830
  res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
5608
5831
  return false;
5609
5832
  }
5833
+ /**
5834
+ * Check if a request is authenticated WITHOUT sending a response.
5835
+ * Used to decide between login page vs dashboard for GET /.
5836
+ */
5837
+ isAuthenticated(req, url) {
5838
+ if (!this.authToken) return true;
5839
+ const authHeader = req.headers.authorization;
5840
+ if (authHeader) {
5841
+ const parts = authHeader.split(" ");
5842
+ if (parts.length === 2 && parts[0] === "Bearer" && parts[1] === this.authToken) {
5843
+ return true;
5844
+ }
5845
+ }
5846
+ const sessionId = url.searchParams.get("session");
5847
+ if (sessionId && this.validateSession(sessionId)) return true;
5848
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5849
+ if (cookieSession && this.validateSession(cookieSession)) return true;
5850
+ return false;
5851
+ }
5852
+ /**
5853
+ * Parse a specific cookie value from the request.
5854
+ */
5855
+ parseCookie(req, name) {
5856
+ const header = req.headers.cookie;
5857
+ if (!header) return null;
5858
+ for (const part of header.split(";")) {
5859
+ const [key, ...rest] = part.split("=");
5860
+ if (key?.trim() === name) {
5861
+ return rest.join("=").trim();
5862
+ }
5863
+ }
5864
+ return null;
5865
+ }
5610
5866
  // ── Session Management (SEC-012) ──────────────────────────────────
5611
5867
  /**
5612
5868
  * Create a short-lived session by exchanging the long-lived auth token
@@ -5627,7 +5883,7 @@ var DashboardApprovalChannel = class {
5627
5883
  this.sessions.set(id, {
5628
5884
  id,
5629
5885
  created_at: now,
5630
- expires_at: now + SESSION_TTL_MS
5886
+ expires_at: now + this.sessionTTLMs
5631
5887
  });
5632
5888
  return id;
5633
5889
  }
@@ -5726,13 +5982,26 @@ var DashboardApprovalChannel = class {
5726
5982
  res.end();
5727
5983
  return;
5728
5984
  }
5729
- if (!this.checkAuth(req, url, res)) return;
5730
- if (!this.checkRateLimit(req, res, "general")) return;
5731
- try {
5732
- if (method === "POST" && url.pathname === "/auth/session") {
5985
+ if (method === "POST" && url.pathname === "/auth/session") {
5986
+ if (!this.checkRateLimit(req, res, "general")) return;
5987
+ try {
5733
5988
  this.handleSessionExchange(req, res);
5989
+ } catch {
5990
+ res.writeHead(500, { "Content-Type": "application/json" });
5991
+ res.end(JSON.stringify({ error: "Internal server error" }));
5992
+ }
5993
+ return;
5994
+ }
5995
+ if (method === "GET" && url.pathname === "/" && this.authToken) {
5996
+ if (!this.isAuthenticated(req, url)) {
5997
+ if (!this.checkRateLimit(req, res, "general")) return;
5998
+ this.serveLoginPage(res);
5734
5999
  return;
5735
6000
  }
6001
+ }
6002
+ if (!this.checkAuth(req, url, res)) return;
6003
+ if (!this.checkRateLimit(req, res, "general")) return;
6004
+ try {
5736
6005
  if (method === "GET" && url.pathname === "/") {
5737
6006
  this.serveDashboard(res);
5738
6007
  } else if (method === "GET" && url.pathname === "/events") {
@@ -5789,12 +6058,23 @@ var DashboardApprovalChannel = class {
5789
6058
  return;
5790
6059
  }
5791
6060
  const sessionId = this.createSession();
5792
- res.writeHead(200, { "Content-Type": "application/json" });
6061
+ const ttlSeconds = Math.floor(this.sessionTTLMs / 1e3);
6062
+ res.writeHead(200, {
6063
+ "Content-Type": "application/json",
6064
+ "Set-Cookie": `sanctuary_session=${sessionId}; Path=/; SameSite=Strict; Max-Age=${ttlSeconds}`
6065
+ });
5793
6066
  res.end(JSON.stringify({
5794
6067
  session_id: sessionId,
5795
- expires_in_seconds: SESSION_TTL_MS / 1e3
6068
+ expires_in_seconds: ttlSeconds
5796
6069
  }));
5797
6070
  }
6071
+ serveLoginPage(res) {
6072
+ res.writeHead(200, {
6073
+ "Content-Type": "text/html; charset=utf-8",
6074
+ "Cache-Control": "no-cache, no-store"
6075
+ });
6076
+ res.end(this.loginHTML);
6077
+ }
5798
6078
  serveDashboard(res) {
5799
6079
  res.writeHead(200, {
5800
6080
  "Content-Type": "text/html; charset=utf-8",
@@ -5970,6 +6250,22 @@ data: ${JSON.stringify(data)}
5970
6250
  broadcastProtectionStatus(data) {
5971
6251
  this.broadcastSSE("protection-status", data);
5972
6252
  }
6253
+ /**
6254
+ * Create a pre-authenticated URL for the dashboard.
6255
+ * Used by the sanctuary_dashboard_open tool and at startup.
6256
+ */
6257
+ createSessionUrl() {
6258
+ const sessionId = this.createSession();
6259
+ const protocol = this.useTLS ? "https" : "http";
6260
+ return `${protocol}://${this.config.host}:${this.config.port}/?session=${sessionId}`;
6261
+ }
6262
+ /**
6263
+ * Get the base URL for the dashboard.
6264
+ */
6265
+ getBaseUrl() {
6266
+ const protocol = this.useTLS ? "https" : "http";
6267
+ return `${protocol}://${this.config.host}:${this.config.port}`;
6268
+ }
5973
6269
  /** Get the number of pending requests */
5974
6270
  get pendingCount() {
5975
6271
  return this.pending.size;
@@ -11784,6 +12080,32 @@ async function createSanctuaryServer(options) {
11784
12080
  } : void 0;
11785
12081
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
11786
12082
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
12083
+ const dashboardTools = [];
12084
+ if (dashboard) {
12085
+ dashboardTools.push({
12086
+ name: "sanctuary/dashboard_open",
12087
+ 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.",
12088
+ inputSchema: {
12089
+ type: "object",
12090
+ properties: {}
12091
+ },
12092
+ handler: async () => {
12093
+ const url = dashboard.createSessionUrl();
12094
+ return {
12095
+ content: [
12096
+ {
12097
+ type: "text",
12098
+ text: JSON.stringify({
12099
+ dashboard_url: url,
12100
+ base_url: dashboard.getBaseUrl(),
12101
+ note: "Click the dashboard_url to open the Principal Dashboard. The session is pre-authenticated."
12102
+ }, null, 2)
12103
+ }
12104
+ ]
12105
+ };
12106
+ }
12107
+ });
12108
+ }
11787
12109
  let allTools = [
11788
12110
  ...l1Tools,
11789
12111
  ...l2Tools,
@@ -11797,6 +12119,7 @@ async function createSanctuaryServer(options) {
11797
12119
  ...auditTools,
11798
12120
  ...contextGateTools,
11799
12121
  ...hardeningTools,
12122
+ ...dashboardTools,
11800
12123
  manifestTool
11801
12124
  ];
11802
12125
  allTools = allTools.map((tool) => ({