@sanctuary-framework/mcp-server 0.5.1 → 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
@@ -4058,6 +4058,181 @@ var AutoApproveChannel = class {
4058
4058
  };
4059
4059
 
4060
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
+ }
4061
4236
  function generateDashboardHTML(options) {
4062
4237
  return `<!DOCTYPE html>
4063
4238
  <html lang="en">
@@ -4927,7 +5102,9 @@ function generateDashboardHTML(options) {
4927
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
4928
5103
 
4929
5104
  const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4930
- 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; } })();
4931
5108
  const MAX_ACTIVITY_ITEMS = 100;
4932
5109
  const MAX_THREAT_ITEMS = 20;
4933
5110
 
@@ -4942,6 +5119,7 @@ function generateDashboardHTML(options) {
4942
5119
  const activityItems = [];
4943
5120
  const threatItems = [];
4944
5121
  let sovereigntyScore = 85;
5122
+ let sessionRenewalTimer = null;
4945
5123
 
4946
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
4947
5125
 
@@ -4957,6 +5135,11 @@ function generateDashboardHTML(options) {
4957
5135
  return url + sep + 'session=' + SESSION_ID;
4958
5136
  }
4959
5137
 
5138
+ function setCookie(sessionId, maxAge) {
5139
+ document.cookie = 'sanctuary_session=' + sessionId +
5140
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
5141
+ }
5142
+
4960
5143
  async function exchangeSession() {
4961
5144
  if (!AUTH_TOKEN) return;
4962
5145
  try {
@@ -4964,14 +5147,35 @@ function generateDashboardHTML(options) {
4964
5147
  if (resp.ok) {
4965
5148
  const data = await resp.json();
4966
5149
  SESSION_ID = data.session_id;
4967
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4968
- 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();
4969
5161
  }
4970
5162
  } catch (e) {
4971
- // 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);
4972
5168
  }
4973
5169
  }
4974
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
+
4975
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
4976
5180
 
4977
5181
  function esc(s) {
@@ -5444,7 +5648,8 @@ function generateDashboardHTML(options) {
5444
5648
  }
5445
5649
 
5446
5650
  // src/principal-policy/dashboard.ts
5447
- 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;
5448
5653
  var MAX_SESSIONS = 1e3;
5449
5654
  var RATE_LIMIT_WINDOW_MS = 6e4;
5450
5655
  var RATE_LIMIT_GENERAL = 120;
@@ -5459,8 +5664,11 @@ var DashboardApprovalChannel = class {
5459
5664
  baseline = null;
5460
5665
  auditLog = null;
5461
5666
  dashboardHTML;
5667
+ loginHTML;
5462
5668
  authToken;
5463
5669
  useTLS;
5670
+ /** Session TTL: longer for localhost, shorter for remote */
5671
+ sessionTTLMs;
5464
5672
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5465
5673
  sessions = /* @__PURE__ */ new Map();
5466
5674
  sessionCleanupTimer = null;
@@ -5470,11 +5678,14 @@ var DashboardApprovalChannel = class {
5470
5678
  this.config = config;
5471
5679
  this.authToken = config.auth_token;
5472
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;
5473
5683
  this.dashboardHTML = generateDashboardHTML({
5474
5684
  timeoutSeconds: config.timeout_seconds,
5475
5685
  serverVersion: SANCTUARY_VERSION,
5476
5686
  authToken: this.authToken
5477
5687
  });
5688
+ this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
5478
5689
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5479
5690
  }
5480
5691
  /**
@@ -5504,25 +5715,26 @@ var DashboardApprovalChannel = class {
5504
5715
  const protocol = this.useTLS ? "https" : "http";
5505
5716
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5506
5717
  this.httpServer.listen(this.config.port, this.config.host, () => {
5507
- if (this.authToken) {
5508
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5509
- process.stderr.write(
5510
- `
5718
+ process.stderr.write(
5719
+ `
5511
5720
  Sanctuary Principal Dashboard: ${baseUrl}
5512
5721
  `
5513
- );
5722
+ );
5723
+ if (this.authToken) {
5724
+ const sessionUrl = this.createSessionUrl();
5514
5725
  process.stderr.write(
5515
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5516
-
5726
+ ` Quick open: ${sessionUrl}
5517
5727
  `
5518
5728
  );
5519
- } else {
5729
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5520
5730
  process.stderr.write(
5521
- `
5522
- Sanctuary Principal Dashboard: ${baseUrl}
5731
+ ` Auth token: ${hint}
5523
5732
 
5524
5733
  `
5525
5734
  );
5735
+ } else {
5736
+ process.stderr.write(`
5737
+ `);
5526
5738
  }
5527
5739
  resolve();
5528
5740
  });
@@ -5626,10 +5838,47 @@ var DashboardApprovalChannel = class {
5626
5838
  if (sessionId && this.validateSession(sessionId)) {
5627
5839
  return true;
5628
5840
  }
5841
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5842
+ if (cookieSession && this.validateSession(cookieSession)) {
5843
+ return true;
5844
+ }
5629
5845
  res.writeHead(401, { "Content-Type": "application/json" });
5630
5846
  res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
5631
5847
  return false;
5632
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
+ }
5633
5882
  // ── Session Management (SEC-012) ──────────────────────────────────
5634
5883
  /**
5635
5884
  * Create a short-lived session by exchanging the long-lived auth token
@@ -5650,7 +5899,7 @@ var DashboardApprovalChannel = class {
5650
5899
  this.sessions.set(id, {
5651
5900
  id,
5652
5901
  created_at: now,
5653
- expires_at: now + SESSION_TTL_MS
5902
+ expires_at: now + this.sessionTTLMs
5654
5903
  });
5655
5904
  return id;
5656
5905
  }
@@ -5749,13 +5998,26 @@ var DashboardApprovalChannel = class {
5749
5998
  res.end();
5750
5999
  return;
5751
6000
  }
5752
- if (!this.checkAuth(req, url, res)) return;
5753
- if (!this.checkRateLimit(req, res, "general")) return;
5754
- try {
5755
- 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 {
5756
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);
5757
6015
  return;
5758
6016
  }
6017
+ }
6018
+ if (!this.checkAuth(req, url, res)) return;
6019
+ if (!this.checkRateLimit(req, res, "general")) return;
6020
+ try {
5759
6021
  if (method === "GET" && url.pathname === "/") {
5760
6022
  this.serveDashboard(res);
5761
6023
  } else if (method === "GET" && url.pathname === "/events") {
@@ -5812,12 +6074,23 @@ var DashboardApprovalChannel = class {
5812
6074
  return;
5813
6075
  }
5814
6076
  const sessionId = this.createSession();
5815
- 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
+ });
5816
6082
  res.end(JSON.stringify({
5817
6083
  session_id: sessionId,
5818
- expires_in_seconds: SESSION_TTL_MS / 1e3
6084
+ expires_in_seconds: ttlSeconds
5819
6085
  }));
5820
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
+ }
5821
6094
  serveDashboard(res) {
5822
6095
  res.writeHead(200, {
5823
6096
  "Content-Type": "text/html; charset=utf-8",
@@ -5993,6 +6266,22 @@ data: ${JSON.stringify(data)}
5993
6266
  broadcastProtectionStatus(data) {
5994
6267
  this.broadcastSSE("protection-status", data);
5995
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
+ }
5996
6285
  /** Get the number of pending requests */
5997
6286
  get pendingCount() {
5998
6287
  return this.pending.size;
@@ -11862,6 +12151,32 @@ async function createSanctuaryServer(options) {
11862
12151
  } : void 0;
11863
12152
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
11864
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
+ }
11865
12180
  let allTools = [
11866
12181
  ...l1Tools,
11867
12182
  ...l2Tools,
@@ -11875,6 +12190,7 @@ async function createSanctuaryServer(options) {
11875
12190
  ...auditTools,
11876
12191
  ...contextGateTools,
11877
12192
  ...hardeningTools,
12193
+ ...dashboardTools,
11878
12194
  manifestTool
11879
12195
  ];
11880
12196
  allTools = allTools.map((tool) => ({