@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.d.cts CHANGED
@@ -58,6 +58,9 @@ interface SanctuaryConfig {
58
58
  }
59
59
  /**
60
60
  * Load configuration from file, falling back to defaults.
61
+ *
62
+ * Precedence (highest wins): CLI flags > env vars > config file > defaults
63
+ * This matches the standard config precedence pattern used by most tools.
61
64
  */
62
65
  declare function loadConfig(configPath?: string): Promise<SanctuaryConfig>;
63
66
 
@@ -1993,8 +1996,11 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
1993
1996
  private baseline;
1994
1997
  private auditLog;
1995
1998
  private dashboardHTML;
1999
+ private loginHTML;
1996
2000
  private authToken;
1997
2001
  private useTLS;
2002
+ /** Session TTL: longer for localhost, shorter for remote */
2003
+ private sessionTTLMs;
1998
2004
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
1999
2005
  private sessions;
2000
2006
  private sessionCleanupTimer;
@@ -2034,6 +2040,15 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
2034
2040
  * Returns true if auth passes, false if blocked (response already sent).
2035
2041
  */
2036
2042
  private checkAuth;
2043
+ /**
2044
+ * Check if a request is authenticated WITHOUT sending a response.
2045
+ * Used to decide between login page vs dashboard for GET /.
2046
+ */
2047
+ private isAuthenticated;
2048
+ /**
2049
+ * Parse a specific cookie value from the request.
2050
+ */
2051
+ private parseCookie;
2037
2052
  /**
2038
2053
  * Create a short-lived session by exchanging the long-lived auth token
2039
2054
  * (provided in the Authorization header) for a session ID.
@@ -2071,6 +2086,7 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
2071
2086
  * normal checkAuth flow.
2072
2087
  */
2073
2088
  private handleSessionExchange;
2089
+ private serveLoginPage;
2074
2090
  private serveDashboard;
2075
2091
  private handleSSE;
2076
2092
  private handleStatus;
@@ -2117,6 +2133,15 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
2117
2133
  * Broadcast current protection status to connected dashboards.
2118
2134
  */
2119
2135
  broadcastProtectionStatus(data: Record<string, unknown>): void;
2136
+ /**
2137
+ * Create a pre-authenticated URL for the dashboard.
2138
+ * Used by the sanctuary_dashboard_open tool and at startup.
2139
+ */
2140
+ createSessionUrl(): string;
2141
+ /**
2142
+ * Get the base URL for the dashboard.
2143
+ */
2144
+ getBaseUrl(): string;
2120
2145
  /** Get the number of pending requests */
2121
2146
  get pendingCount(): number;
2122
2147
  /** Get the number of connected SSE clients */
package/dist/index.d.ts CHANGED
@@ -58,6 +58,9 @@ interface SanctuaryConfig {
58
58
  }
59
59
  /**
60
60
  * Load configuration from file, falling back to defaults.
61
+ *
62
+ * Precedence (highest wins): CLI flags > env vars > config file > defaults
63
+ * This matches the standard config precedence pattern used by most tools.
61
64
  */
62
65
  declare function loadConfig(configPath?: string): Promise<SanctuaryConfig>;
63
66
 
@@ -1993,8 +1996,11 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
1993
1996
  private baseline;
1994
1997
  private auditLog;
1995
1998
  private dashboardHTML;
1999
+ private loginHTML;
1996
2000
  private authToken;
1997
2001
  private useTLS;
2002
+ /** Session TTL: longer for localhost, shorter for remote */
2003
+ private sessionTTLMs;
1998
2004
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
1999
2005
  private sessions;
2000
2006
  private sessionCleanupTimer;
@@ -2034,6 +2040,15 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
2034
2040
  * Returns true if auth passes, false if blocked (response already sent).
2035
2041
  */
2036
2042
  private checkAuth;
2043
+ /**
2044
+ * Check if a request is authenticated WITHOUT sending a response.
2045
+ * Used to decide between login page vs dashboard for GET /.
2046
+ */
2047
+ private isAuthenticated;
2048
+ /**
2049
+ * Parse a specific cookie value from the request.
2050
+ */
2051
+ private parseCookie;
2037
2052
  /**
2038
2053
  * Create a short-lived session by exchanging the long-lived auth token
2039
2054
  * (provided in the Authorization header) for a session ID.
@@ -2071,6 +2086,7 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
2071
2086
  * normal checkAuth flow.
2072
2087
  */
2073
2088
  private handleSessionExchange;
2089
+ private serveLoginPage;
2074
2090
  private serveDashboard;
2075
2091
  private handleSSE;
2076
2092
  private handleStatus;
@@ -2117,6 +2133,15 @@ declare class DashboardApprovalChannel implements ApprovalChannel {
2117
2133
  * Broadcast current protection status to connected dashboards.
2118
2134
  */
2119
2135
  broadcastProtectionStatus(data: Record<string, unknown>): void;
2136
+ /**
2137
+ * Create a pre-authenticated URL for the dashboard.
2138
+ * Used by the sanctuary_dashboard_open tool and at startup.
2139
+ */
2140
+ createSessionUrl(): string;
2141
+ /**
2142
+ * Get the base URL for the dashboard.
2143
+ */
2144
+ getBaseUrl(): string;
2120
2145
  /** Get the number of pending requests */
2121
2146
  get pendingCount(): number;
2122
2147
  /** Get the number of connected SSE clients */
package/dist/index.js CHANGED
@@ -254,7 +254,18 @@ function defaultConfig() {
254
254
  };
255
255
  }
256
256
  async function loadConfig(configPath) {
257
- const config = defaultConfig();
257
+ let config = defaultConfig();
258
+ const storagePath = process.env.SANCTUARY_STORAGE_PATH ?? config.storage_path;
259
+ const path = configPath ?? join(storagePath, "sanctuary.json");
260
+ try {
261
+ const raw = await readFile(path, "utf-8");
262
+ const fileConfig = JSON.parse(raw);
263
+ config = deepMerge(config, fileConfig);
264
+ } catch (err) {
265
+ if (err instanceof Error && err.message.includes("unimplemented features")) {
266
+ throw err;
267
+ }
268
+ }
258
269
  if (process.env.SANCTUARY_STORAGE_PATH) {
259
270
  config.storage_path = process.env.SANCTUARY_STORAGE_PATH;
260
271
  }
@@ -267,6 +278,9 @@ async function loadConfig(configPath) {
267
278
  if (process.env.SANCTUARY_DASHBOARD_ENABLED === "true") {
268
279
  config.dashboard.enabled = true;
269
280
  }
281
+ if (process.env.SANCTUARY_DASHBOARD_ENABLED === "false") {
282
+ config.dashboard.enabled = false;
283
+ }
270
284
  if (process.env.SANCTUARY_DASHBOARD_PORT) {
271
285
  config.dashboard.port = parseInt(process.env.SANCTUARY_DASHBOARD_PORT, 10);
272
286
  }
@@ -285,6 +299,9 @@ async function loadConfig(configPath) {
285
299
  if (process.env.SANCTUARY_WEBHOOK_ENABLED === "true") {
286
300
  config.webhook.enabled = true;
287
301
  }
302
+ if (process.env.SANCTUARY_WEBHOOK_ENABLED === "false") {
303
+ config.webhook.enabled = false;
304
+ }
288
305
  if (process.env.SANCTUARY_WEBHOOK_URL) {
289
306
  config.webhook.url = process.env.SANCTUARY_WEBHOOK_URL;
290
307
  }
@@ -297,19 +314,9 @@ async function loadConfig(configPath) {
297
314
  if (process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST) {
298
315
  config.webhook.callback_host = process.env.SANCTUARY_WEBHOOK_CALLBACK_HOST;
299
316
  }
300
- const path = configPath ?? join(config.storage_path, "sanctuary.json");
301
- try {
302
- const raw = await readFile(path, "utf-8");
303
- const fileConfig = JSON.parse(raw);
304
- const merged = deepMerge(config, fileConfig);
305
- validateConfig(merged);
306
- return merged;
307
- } catch (err) {
308
- if (err instanceof Error && err.message.includes("unimplemented features")) {
309
- throw err;
310
- }
311
- return config;
312
- }
317
+ config.version = PKG_VERSION;
318
+ validateConfig(config);
319
+ return config;
313
320
  }
314
321
  async function saveConfig(config, configPath) {
315
322
  const path = join(config.storage_path, "sanctuary.json");
@@ -4048,6 +4055,181 @@ var AutoApproveChannel = class {
4048
4055
  };
4049
4056
 
4050
4057
  // src/principal-policy/dashboard-html.ts
4058
+ function generateLoginHTML(options) {
4059
+ return `<!DOCTYPE html>
4060
+ <html lang="en">
4061
+ <head>
4062
+ <meta charset="utf-8">
4063
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4064
+ <title>Sanctuary \u2014 Login</title>
4065
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
4066
+ <style>
4067
+ :root {
4068
+ --bg: #0d1117;
4069
+ --surface: #161b22;
4070
+ --border: #30363d;
4071
+ --text-primary: #e6edf3;
4072
+ --text-secondary: #8b949e;
4073
+ --green: #3fb950;
4074
+ --red: #f85149;
4075
+ --blue: #58a6ff;
4076
+ --mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
4077
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
4078
+ --radius: 6px;
4079
+ }
4080
+ * { box-sizing: border-box; margin: 0; padding: 0; }
4081
+ html, body { width: 100%; height: 100%; }
4082
+ body {
4083
+ font-family: var(--sans);
4084
+ background: var(--bg);
4085
+ color: var(--text-primary);
4086
+ display: flex;
4087
+ align-items: center;
4088
+ justify-content: center;
4089
+ }
4090
+ .login-container {
4091
+ width: 100%;
4092
+ max-width: 400px;
4093
+ padding: 40px 32px;
4094
+ background: var(--surface);
4095
+ border: 1px solid var(--border);
4096
+ border-radius: 12px;
4097
+ }
4098
+ .login-logo {
4099
+ text-align: center;
4100
+ font-size: 20px;
4101
+ font-weight: 700;
4102
+ letter-spacing: -0.5px;
4103
+ margin-bottom: 8px;
4104
+ }
4105
+ .login-logo span { color: var(--blue); }
4106
+ .login-version {
4107
+ text-align: center;
4108
+ font-size: 11px;
4109
+ color: var(--text-secondary);
4110
+ font-family: var(--mono);
4111
+ margin-bottom: 32px;
4112
+ }
4113
+ .login-label {
4114
+ display: block;
4115
+ font-size: 13px;
4116
+ font-weight: 600;
4117
+ color: var(--text-secondary);
4118
+ margin-bottom: 8px;
4119
+ }
4120
+ .login-input {
4121
+ width: 100%;
4122
+ padding: 10px 14px;
4123
+ background: var(--bg);
4124
+ border: 1px solid var(--border);
4125
+ border-radius: var(--radius);
4126
+ color: var(--text-primary);
4127
+ font-family: var(--mono);
4128
+ font-size: 14px;
4129
+ outline: none;
4130
+ transition: border-color 0.15s;
4131
+ }
4132
+ .login-input:focus { border-color: var(--blue); }
4133
+ .login-input::placeholder { color: var(--text-secondary); opacity: 0.5; }
4134
+ .login-btn {
4135
+ width: 100%;
4136
+ margin-top: 20px;
4137
+ padding: 10px;
4138
+ background: var(--blue);
4139
+ color: var(--bg);
4140
+ border: none;
4141
+ border-radius: var(--radius);
4142
+ font-size: 14px;
4143
+ font-weight: 600;
4144
+ cursor: pointer;
4145
+ transition: opacity 0.15s;
4146
+ font-family: var(--sans);
4147
+ }
4148
+ .login-btn:hover { opacity: 0.9; }
4149
+ .login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4150
+ .login-error {
4151
+ margin-top: 16px;
4152
+ padding: 10px 14px;
4153
+ background: rgba(248, 81, 73, 0.1);
4154
+ border: 1px solid var(--red);
4155
+ border-radius: var(--radius);
4156
+ font-size: 12px;
4157
+ color: var(--red);
4158
+ display: none;
4159
+ }
4160
+ .login-hint {
4161
+ margin-top: 24px;
4162
+ padding-top: 16px;
4163
+ border-top: 1px solid var(--border);
4164
+ font-size: 11px;
4165
+ color: var(--text-secondary);
4166
+ line-height: 1.5;
4167
+ }
4168
+ .login-hint code {
4169
+ font-family: var(--mono);
4170
+ background: var(--bg);
4171
+ padding: 1px 4px;
4172
+ border-radius: 3px;
4173
+ font-size: 10px;
4174
+ }
4175
+ </style>
4176
+ </head>
4177
+ <body>
4178
+ <div class="login-container">
4179
+ <div class="login-logo"><span>&#9670;</span> SANCTUARY</div>
4180
+ <div class="login-version">Principal Dashboard v${options.serverVersion}</div>
4181
+ <form id="loginForm" onsubmit="return handleLogin(event)">
4182
+ <label class="login-label" for="tokenInput">Dashboard Auth Token</label>
4183
+ <input class="login-input" type="password" id="tokenInput"
4184
+ placeholder="Enter your auth token" autocomplete="off" autofocus required>
4185
+ <button class="login-btn" type="submit" id="loginBtn">Open Dashboard</button>
4186
+ </form>
4187
+ <div class="login-error" id="loginError"></div>
4188
+ <div class="login-hint">
4189
+ Your token is set via <code>SANCTUARY_DASHBOARD_AUTH_TOKEN</code> environment variable,
4190
+ or check your server's startup output.
4191
+ </div>
4192
+ </div>
4193
+ <script>
4194
+ async function handleLogin(e) {
4195
+ e.preventDefault();
4196
+ var btn = document.getElementById('loginBtn');
4197
+ var errEl = document.getElementById('loginError');
4198
+ var token = document.getElementById('tokenInput').value.trim();
4199
+ if (!token) return false;
4200
+ btn.disabled = true;
4201
+ btn.textContent = 'Authenticating...';
4202
+ errEl.style.display = 'none';
4203
+ try {
4204
+ var resp = await fetch('/auth/session', {
4205
+ method: 'POST',
4206
+ headers: { 'Authorization': 'Bearer ' + token }
4207
+ });
4208
+ if (!resp.ok) {
4209
+ var data = await resp.json().catch(function() { return {}; });
4210
+ throw new Error(data.error || 'Authentication failed');
4211
+ }
4212
+ var result = await resp.json();
4213
+ // Store token in sessionStorage for auto-renewal inside the dashboard
4214
+ try { sessionStorage.setItem('sanctuary_token', token); } catch(_) {}
4215
+ // Set session cookie
4216
+ var maxAge = result.expires_in_seconds || 300;
4217
+ document.cookie = 'sanctuary_session=' + result.session_id +
4218
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
4219
+ // Reload to enter the dashboard
4220
+ window.location.reload();
4221
+ } catch (err) {
4222
+ errEl.textContent = err.message || 'Authentication failed. Check your token.';
4223
+ errEl.style.display = 'block';
4224
+ btn.disabled = false;
4225
+ btn.textContent = 'Open Dashboard';
4226
+ }
4227
+ return false;
4228
+ }
4229
+ </script>
4230
+ </body>
4231
+ </html>`;
4232
+ }
4051
4233
  function generateDashboardHTML(options) {
4052
4234
  return `<!DOCTYPE html>
4053
4235
  <html lang="en">
@@ -4917,7 +5099,9 @@ function generateDashboardHTML(options) {
4917
5099
  // \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
4918
5100
 
4919
5101
  const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4920
- const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5102
+ // AUTH_TOKEN: embedded token (for direct session access) or from sessionStorage (login page flow)
5103
+ const EMBEDDED_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5104
+ const AUTH_TOKEN = EMBEDDED_TOKEN || (function() { try { return sessionStorage.getItem('sanctuary_token'); } catch(_) { return null; } })();
4921
5105
  const MAX_ACTIVITY_ITEMS = 100;
4922
5106
  const MAX_THREAT_ITEMS = 20;
4923
5107
 
@@ -4932,6 +5116,7 @@ function generateDashboardHTML(options) {
4932
5116
  const activityItems = [];
4933
5117
  const threatItems = [];
4934
5118
  let sovereigntyScore = 85;
5119
+ let sessionRenewalTimer = null;
4935
5120
 
4936
5121
  // \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
4937
5122
 
@@ -4947,6 +5132,11 @@ function generateDashboardHTML(options) {
4947
5132
  return url + sep + 'session=' + SESSION_ID;
4948
5133
  }
4949
5134
 
5135
+ function setCookie(sessionId, maxAge) {
5136
+ document.cookie = 'sanctuary_session=' + sessionId +
5137
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
5138
+ }
5139
+
4950
5140
  async function exchangeSession() {
4951
5141
  if (!AUTH_TOKEN) return;
4952
5142
  try {
@@ -4954,14 +5144,35 @@ function generateDashboardHTML(options) {
4954
5144
  if (resp.ok) {
4955
5145
  const data = await resp.json();
4956
5146
  SESSION_ID = data.session_id;
4957
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4958
- setTimeout(() => { exchangeSession(); reconnectSSE(); }, refreshMs);
5147
+ var ttl = data.expires_in_seconds || 300;
5148
+ // Update cookie with new session
5149
+ setCookie(SESSION_ID, ttl);
5150
+ // Schedule renewal at 80% of TTL
5151
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5152
+ sessionRenewalTimer = setTimeout(function() {
5153
+ exchangeSession().then(function() { reconnectSSE(); });
5154
+ }, ttl * 800);
5155
+ } else if (resp.status === 401) {
5156
+ // Token invalid or expired \u2014 show non-destructive re-login overlay
5157
+ showSessionExpired();
4959
5158
  }
4960
5159
  } catch (e) {
4961
- // Retry on next connect
5160
+ // Network error \u2014 retry in 30s
5161
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5162
+ sessionRenewalTimer = setTimeout(function() {
5163
+ exchangeSession().then(function() { reconnectSSE(); });
5164
+ }, 30000);
4962
5165
  }
4963
5166
  }
4964
5167
 
5168
+ function showSessionExpired() {
5169
+ // Clear stored token
5170
+ try { sessionStorage.removeItem('sanctuary_token'); } catch(_) {}
5171
+ // Redirect to login page
5172
+ document.cookie = 'sanctuary_session=; path=/; max-age=0';
5173
+ window.location.reload();
5174
+ }
5175
+
4965
5176
  // \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
4966
5177
 
4967
5178
  function esc(s) {
@@ -5434,7 +5645,8 @@ function generateDashboardHTML(options) {
5434
5645
  }
5435
5646
 
5436
5647
  // src/principal-policy/dashboard.ts
5437
- var SESSION_TTL_MS = 5 * 60 * 1e3;
5648
+ var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
5649
+ var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
5438
5650
  var MAX_SESSIONS = 1e3;
5439
5651
  var RATE_LIMIT_WINDOW_MS = 6e4;
5440
5652
  var RATE_LIMIT_GENERAL = 120;
@@ -5449,8 +5661,11 @@ var DashboardApprovalChannel = class {
5449
5661
  baseline = null;
5450
5662
  auditLog = null;
5451
5663
  dashboardHTML;
5664
+ loginHTML;
5452
5665
  authToken;
5453
5666
  useTLS;
5667
+ /** Session TTL: longer for localhost, shorter for remote */
5668
+ sessionTTLMs;
5454
5669
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5455
5670
  sessions = /* @__PURE__ */ new Map();
5456
5671
  sessionCleanupTimer = null;
@@ -5460,11 +5675,14 @@ var DashboardApprovalChannel = class {
5460
5675
  this.config = config;
5461
5676
  this.authToken = config.auth_token;
5462
5677
  this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
5678
+ const isLocalhost = config.host === "127.0.0.1" || config.host === "localhost" || config.host === "::1";
5679
+ this.sessionTTLMs = isLocalhost ? SESSION_TTL_LOCAL_MS : SESSION_TTL_REMOTE_MS;
5463
5680
  this.dashboardHTML = generateDashboardHTML({
5464
5681
  timeoutSeconds: config.timeout_seconds,
5465
5682
  serverVersion: SANCTUARY_VERSION,
5466
5683
  authToken: this.authToken
5467
5684
  });
5685
+ this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
5468
5686
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5469
5687
  }
5470
5688
  /**
@@ -5494,25 +5712,26 @@ var DashboardApprovalChannel = class {
5494
5712
  const protocol = this.useTLS ? "https" : "http";
5495
5713
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5496
5714
  this.httpServer.listen(this.config.port, this.config.host, () => {
5497
- if (this.authToken) {
5498
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5499
- process.stderr.write(
5500
- `
5715
+ process.stderr.write(
5716
+ `
5501
5717
  Sanctuary Principal Dashboard: ${baseUrl}
5502
5718
  `
5503
- );
5719
+ );
5720
+ if (this.authToken) {
5721
+ const sessionUrl = this.createSessionUrl();
5504
5722
  process.stderr.write(
5505
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5506
-
5723
+ ` Quick open: ${sessionUrl}
5507
5724
  `
5508
5725
  );
5509
- } else {
5726
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5510
5727
  process.stderr.write(
5511
- `
5512
- Sanctuary Principal Dashboard: ${baseUrl}
5728
+ ` Auth token: ${hint}
5513
5729
 
5514
5730
  `
5515
5731
  );
5732
+ } else {
5733
+ process.stderr.write(`
5734
+ `);
5516
5735
  }
5517
5736
  resolve();
5518
5737
  });
@@ -5616,10 +5835,47 @@ var DashboardApprovalChannel = class {
5616
5835
  if (sessionId && this.validateSession(sessionId)) {
5617
5836
  return true;
5618
5837
  }
5838
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5839
+ if (cookieSession && this.validateSession(cookieSession)) {
5840
+ return true;
5841
+ }
5619
5842
  res.writeHead(401, { "Content-Type": "application/json" });
5620
5843
  res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
5621
5844
  return false;
5622
5845
  }
5846
+ /**
5847
+ * Check if a request is authenticated WITHOUT sending a response.
5848
+ * Used to decide between login page vs dashboard for GET /.
5849
+ */
5850
+ isAuthenticated(req, url) {
5851
+ if (!this.authToken) return true;
5852
+ const authHeader = req.headers.authorization;
5853
+ if (authHeader) {
5854
+ const parts = authHeader.split(" ");
5855
+ if (parts.length === 2 && parts[0] === "Bearer" && parts[1] === this.authToken) {
5856
+ return true;
5857
+ }
5858
+ }
5859
+ const sessionId = url.searchParams.get("session");
5860
+ if (sessionId && this.validateSession(sessionId)) return true;
5861
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5862
+ if (cookieSession && this.validateSession(cookieSession)) return true;
5863
+ return false;
5864
+ }
5865
+ /**
5866
+ * Parse a specific cookie value from the request.
5867
+ */
5868
+ parseCookie(req, name) {
5869
+ const header = req.headers.cookie;
5870
+ if (!header) return null;
5871
+ for (const part of header.split(";")) {
5872
+ const [key, ...rest] = part.split("=");
5873
+ if (key?.trim() === name) {
5874
+ return rest.join("=").trim();
5875
+ }
5876
+ }
5877
+ return null;
5878
+ }
5623
5879
  // ── Session Management (SEC-012) ──────────────────────────────────
5624
5880
  /**
5625
5881
  * Create a short-lived session by exchanging the long-lived auth token
@@ -5640,7 +5896,7 @@ var DashboardApprovalChannel = class {
5640
5896
  this.sessions.set(id, {
5641
5897
  id,
5642
5898
  created_at: now,
5643
- expires_at: now + SESSION_TTL_MS
5899
+ expires_at: now + this.sessionTTLMs
5644
5900
  });
5645
5901
  return id;
5646
5902
  }
@@ -5739,13 +5995,26 @@ var DashboardApprovalChannel = class {
5739
5995
  res.end();
5740
5996
  return;
5741
5997
  }
5742
- if (!this.checkAuth(req, url, res)) return;
5743
- if (!this.checkRateLimit(req, res, "general")) return;
5744
- try {
5745
- if (method === "POST" && url.pathname === "/auth/session") {
5998
+ if (method === "POST" && url.pathname === "/auth/session") {
5999
+ if (!this.checkRateLimit(req, res, "general")) return;
6000
+ try {
5746
6001
  this.handleSessionExchange(req, res);
6002
+ } catch {
6003
+ res.writeHead(500, { "Content-Type": "application/json" });
6004
+ res.end(JSON.stringify({ error: "Internal server error" }));
6005
+ }
6006
+ return;
6007
+ }
6008
+ if (method === "GET" && url.pathname === "/" && this.authToken) {
6009
+ if (!this.isAuthenticated(req, url)) {
6010
+ if (!this.checkRateLimit(req, res, "general")) return;
6011
+ this.serveLoginPage(res);
5747
6012
  return;
5748
6013
  }
6014
+ }
6015
+ if (!this.checkAuth(req, url, res)) return;
6016
+ if (!this.checkRateLimit(req, res, "general")) return;
6017
+ try {
5749
6018
  if (method === "GET" && url.pathname === "/") {
5750
6019
  this.serveDashboard(res);
5751
6020
  } else if (method === "GET" && url.pathname === "/events") {
@@ -5802,12 +6071,23 @@ var DashboardApprovalChannel = class {
5802
6071
  return;
5803
6072
  }
5804
6073
  const sessionId = this.createSession();
5805
- res.writeHead(200, { "Content-Type": "application/json" });
6074
+ const ttlSeconds = Math.floor(this.sessionTTLMs / 1e3);
6075
+ res.writeHead(200, {
6076
+ "Content-Type": "application/json",
6077
+ "Set-Cookie": `sanctuary_session=${sessionId}; Path=/; SameSite=Strict; Max-Age=${ttlSeconds}`
6078
+ });
5806
6079
  res.end(JSON.stringify({
5807
6080
  session_id: sessionId,
5808
- expires_in_seconds: SESSION_TTL_MS / 1e3
6081
+ expires_in_seconds: ttlSeconds
5809
6082
  }));
5810
6083
  }
6084
+ serveLoginPage(res) {
6085
+ res.writeHead(200, {
6086
+ "Content-Type": "text/html; charset=utf-8",
6087
+ "Cache-Control": "no-cache, no-store"
6088
+ });
6089
+ res.end(this.loginHTML);
6090
+ }
5811
6091
  serveDashboard(res) {
5812
6092
  res.writeHead(200, {
5813
6093
  "Content-Type": "text/html; charset=utf-8",
@@ -5983,6 +6263,22 @@ data: ${JSON.stringify(data)}
5983
6263
  broadcastProtectionStatus(data) {
5984
6264
  this.broadcastSSE("protection-status", data);
5985
6265
  }
6266
+ /**
6267
+ * Create a pre-authenticated URL for the dashboard.
6268
+ * Used by the sanctuary_dashboard_open tool and at startup.
6269
+ */
6270
+ createSessionUrl() {
6271
+ const sessionId = this.createSession();
6272
+ const protocol = this.useTLS ? "https" : "http";
6273
+ return `${protocol}://${this.config.host}:${this.config.port}/?session=${sessionId}`;
6274
+ }
6275
+ /**
6276
+ * Get the base URL for the dashboard.
6277
+ */
6278
+ getBaseUrl() {
6279
+ const protocol = this.useTLS ? "https" : "http";
6280
+ return `${protocol}://${this.config.host}:${this.config.port}`;
6281
+ }
5986
6282
  /** Get the number of pending requests */
5987
6283
  get pendingCount() {
5988
6284
  return this.pending.size;
@@ -11852,6 +12148,32 @@ async function createSanctuaryServer(options) {
11852
12148
  } : void 0;
11853
12149
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
11854
12150
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
12151
+ const dashboardTools = [];
12152
+ if (dashboard) {
12153
+ dashboardTools.push({
12154
+ name: "sanctuary/dashboard_open",
12155
+ 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.",
12156
+ inputSchema: {
12157
+ type: "object",
12158
+ properties: {}
12159
+ },
12160
+ handler: async () => {
12161
+ const url = dashboard.createSessionUrl();
12162
+ return {
12163
+ content: [
12164
+ {
12165
+ type: "text",
12166
+ text: JSON.stringify({
12167
+ dashboard_url: url,
12168
+ base_url: dashboard.getBaseUrl(),
12169
+ note: "Click the dashboard_url to open the Principal Dashboard. The session is pre-authenticated."
12170
+ }, null, 2)
12171
+ }
12172
+ ]
12173
+ };
12174
+ }
12175
+ });
12176
+ }
11855
12177
  let allTools = [
11856
12178
  ...l1Tools,
11857
12179
  ...l2Tools,
@@ -11865,6 +12187,7 @@ async function createSanctuaryServer(options) {
11865
12187
  ...auditTools,
11866
12188
  ...contextGateTools,
11867
12189
  ...hardeningTools,
12190
+ ...dashboardTools,
11868
12191
  manifestTool
11869
12192
  ];
11870
12193
  allTools = allTools.map((tool) => ({