@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/cli.js CHANGED
@@ -4039,6 +4039,181 @@ var StderrApprovalChannel = class {
4039
4039
  };
4040
4040
 
4041
4041
  // src/principal-policy/dashboard-html.ts
4042
+ function generateLoginHTML(options) {
4043
+ return `<!DOCTYPE html>
4044
+ <html lang="en">
4045
+ <head>
4046
+ <meta charset="utf-8">
4047
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4048
+ <title>Sanctuary \u2014 Login</title>
4049
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
4050
+ <style>
4051
+ :root {
4052
+ --bg: #0d1117;
4053
+ --surface: #161b22;
4054
+ --border: #30363d;
4055
+ --text-primary: #e6edf3;
4056
+ --text-secondary: #8b949e;
4057
+ --green: #3fb950;
4058
+ --red: #f85149;
4059
+ --blue: #58a6ff;
4060
+ --mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
4061
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
4062
+ --radius: 6px;
4063
+ }
4064
+ * { box-sizing: border-box; margin: 0; padding: 0; }
4065
+ html, body { width: 100%; height: 100%; }
4066
+ body {
4067
+ font-family: var(--sans);
4068
+ background: var(--bg);
4069
+ color: var(--text-primary);
4070
+ display: flex;
4071
+ align-items: center;
4072
+ justify-content: center;
4073
+ }
4074
+ .login-container {
4075
+ width: 100%;
4076
+ max-width: 400px;
4077
+ padding: 40px 32px;
4078
+ background: var(--surface);
4079
+ border: 1px solid var(--border);
4080
+ border-radius: 12px;
4081
+ }
4082
+ .login-logo {
4083
+ text-align: center;
4084
+ font-size: 20px;
4085
+ font-weight: 700;
4086
+ letter-spacing: -0.5px;
4087
+ margin-bottom: 8px;
4088
+ }
4089
+ .login-logo span { color: var(--blue); }
4090
+ .login-version {
4091
+ text-align: center;
4092
+ font-size: 11px;
4093
+ color: var(--text-secondary);
4094
+ font-family: var(--mono);
4095
+ margin-bottom: 32px;
4096
+ }
4097
+ .login-label {
4098
+ display: block;
4099
+ font-size: 13px;
4100
+ font-weight: 600;
4101
+ color: var(--text-secondary);
4102
+ margin-bottom: 8px;
4103
+ }
4104
+ .login-input {
4105
+ width: 100%;
4106
+ padding: 10px 14px;
4107
+ background: var(--bg);
4108
+ border: 1px solid var(--border);
4109
+ border-radius: var(--radius);
4110
+ color: var(--text-primary);
4111
+ font-family: var(--mono);
4112
+ font-size: 14px;
4113
+ outline: none;
4114
+ transition: border-color 0.15s;
4115
+ }
4116
+ .login-input:focus { border-color: var(--blue); }
4117
+ .login-input::placeholder { color: var(--text-secondary); opacity: 0.5; }
4118
+ .login-btn {
4119
+ width: 100%;
4120
+ margin-top: 20px;
4121
+ padding: 10px;
4122
+ background: var(--blue);
4123
+ color: var(--bg);
4124
+ border: none;
4125
+ border-radius: var(--radius);
4126
+ font-size: 14px;
4127
+ font-weight: 600;
4128
+ cursor: pointer;
4129
+ transition: opacity 0.15s;
4130
+ font-family: var(--sans);
4131
+ }
4132
+ .login-btn:hover { opacity: 0.9; }
4133
+ .login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4134
+ .login-error {
4135
+ margin-top: 16px;
4136
+ padding: 10px 14px;
4137
+ background: rgba(248, 81, 73, 0.1);
4138
+ border: 1px solid var(--red);
4139
+ border-radius: var(--radius);
4140
+ font-size: 12px;
4141
+ color: var(--red);
4142
+ display: none;
4143
+ }
4144
+ .login-hint {
4145
+ margin-top: 24px;
4146
+ padding-top: 16px;
4147
+ border-top: 1px solid var(--border);
4148
+ font-size: 11px;
4149
+ color: var(--text-secondary);
4150
+ line-height: 1.5;
4151
+ }
4152
+ .login-hint code {
4153
+ font-family: var(--mono);
4154
+ background: var(--bg);
4155
+ padding: 1px 4px;
4156
+ border-radius: 3px;
4157
+ font-size: 10px;
4158
+ }
4159
+ </style>
4160
+ </head>
4161
+ <body>
4162
+ <div class="login-container">
4163
+ <div class="login-logo"><span>&#9670;</span> SANCTUARY</div>
4164
+ <div class="login-version">Principal Dashboard v${options.serverVersion}</div>
4165
+ <form id="loginForm" onsubmit="return handleLogin(event)">
4166
+ <label class="login-label" for="tokenInput">Dashboard Auth Token</label>
4167
+ <input class="login-input" type="password" id="tokenInput"
4168
+ placeholder="Enter your auth token" autocomplete="off" autofocus required>
4169
+ <button class="login-btn" type="submit" id="loginBtn">Open Dashboard</button>
4170
+ </form>
4171
+ <div class="login-error" id="loginError"></div>
4172
+ <div class="login-hint">
4173
+ Your token is set via <code>SANCTUARY_DASHBOARD_AUTH_TOKEN</code> environment variable,
4174
+ or check your server's startup output.
4175
+ </div>
4176
+ </div>
4177
+ <script>
4178
+ async function handleLogin(e) {
4179
+ e.preventDefault();
4180
+ var btn = document.getElementById('loginBtn');
4181
+ var errEl = document.getElementById('loginError');
4182
+ var token = document.getElementById('tokenInput').value.trim();
4183
+ if (!token) return false;
4184
+ btn.disabled = true;
4185
+ btn.textContent = 'Authenticating...';
4186
+ errEl.style.display = 'none';
4187
+ try {
4188
+ var resp = await fetch('/auth/session', {
4189
+ method: 'POST',
4190
+ headers: { 'Authorization': 'Bearer ' + token }
4191
+ });
4192
+ if (!resp.ok) {
4193
+ var data = await resp.json().catch(function() { return {}; });
4194
+ throw new Error(data.error || 'Authentication failed');
4195
+ }
4196
+ var result = await resp.json();
4197
+ // Store token in sessionStorage for auto-renewal inside the dashboard
4198
+ try { sessionStorage.setItem('sanctuary_token', token); } catch(_) {}
4199
+ // Set session cookie
4200
+ var maxAge = result.expires_in_seconds || 300;
4201
+ document.cookie = 'sanctuary_session=' + result.session_id +
4202
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
4203
+ // Reload to enter the dashboard
4204
+ window.location.reload();
4205
+ } catch (err) {
4206
+ errEl.textContent = err.message || 'Authentication failed. Check your token.';
4207
+ errEl.style.display = 'block';
4208
+ btn.disabled = false;
4209
+ btn.textContent = 'Open Dashboard';
4210
+ }
4211
+ return false;
4212
+ }
4213
+ </script>
4214
+ </body>
4215
+ </html>`;
4216
+ }
4042
4217
  function generateDashboardHTML(options) {
4043
4218
  return `<!DOCTYPE html>
4044
4219
  <html lang="en">
@@ -4908,7 +5083,9 @@ function generateDashboardHTML(options) {
4908
5083
  // \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
4909
5084
 
4910
5085
  const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4911
- const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5086
+ // AUTH_TOKEN: embedded token (for direct session access) or from sessionStorage (login page flow)
5087
+ const EMBEDDED_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
5088
+ const AUTH_TOKEN = EMBEDDED_TOKEN || (function() { try { return sessionStorage.getItem('sanctuary_token'); } catch(_) { return null; } })();
4912
5089
  const MAX_ACTIVITY_ITEMS = 100;
4913
5090
  const MAX_THREAT_ITEMS = 20;
4914
5091
 
@@ -4923,6 +5100,7 @@ function generateDashboardHTML(options) {
4923
5100
  const activityItems = [];
4924
5101
  const threatItems = [];
4925
5102
  let sovereigntyScore = 85;
5103
+ let sessionRenewalTimer = null;
4926
5104
 
4927
5105
  // \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
4928
5106
 
@@ -4938,6 +5116,11 @@ function generateDashboardHTML(options) {
4938
5116
  return url + sep + 'session=' + SESSION_ID;
4939
5117
  }
4940
5118
 
5119
+ function setCookie(sessionId, maxAge) {
5120
+ document.cookie = 'sanctuary_session=' + sessionId +
5121
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
5122
+ }
5123
+
4941
5124
  async function exchangeSession() {
4942
5125
  if (!AUTH_TOKEN) return;
4943
5126
  try {
@@ -4945,14 +5128,35 @@ function generateDashboardHTML(options) {
4945
5128
  if (resp.ok) {
4946
5129
  const data = await resp.json();
4947
5130
  SESSION_ID = data.session_id;
4948
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4949
- setTimeout(() => { exchangeSession(); reconnectSSE(); }, refreshMs);
5131
+ var ttl = data.expires_in_seconds || 300;
5132
+ // Update cookie with new session
5133
+ setCookie(SESSION_ID, ttl);
5134
+ // Schedule renewal at 80% of TTL
5135
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5136
+ sessionRenewalTimer = setTimeout(function() {
5137
+ exchangeSession().then(function() { reconnectSSE(); });
5138
+ }, ttl * 800);
5139
+ } else if (resp.status === 401) {
5140
+ // Token invalid or expired \u2014 show non-destructive re-login overlay
5141
+ showSessionExpired();
4950
5142
  }
4951
5143
  } catch (e) {
4952
- // Retry on next connect
5144
+ // Network error \u2014 retry in 30s
5145
+ if (sessionRenewalTimer) clearTimeout(sessionRenewalTimer);
5146
+ sessionRenewalTimer = setTimeout(function() {
5147
+ exchangeSession().then(function() { reconnectSSE(); });
5148
+ }, 30000);
4953
5149
  }
4954
5150
  }
4955
5151
 
5152
+ function showSessionExpired() {
5153
+ // Clear stored token
5154
+ try { sessionStorage.removeItem('sanctuary_token'); } catch(_) {}
5155
+ // Redirect to login page
5156
+ document.cookie = 'sanctuary_session=; path=/; max-age=0';
5157
+ window.location.reload();
5158
+ }
5159
+
4956
5160
  // \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
4957
5161
 
4958
5162
  function esc(s) {
@@ -5425,7 +5629,8 @@ function generateDashboardHTML(options) {
5425
5629
  }
5426
5630
 
5427
5631
  // src/principal-policy/dashboard.ts
5428
- var SESSION_TTL_MS = 5 * 60 * 1e3;
5632
+ var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
5633
+ var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
5429
5634
  var MAX_SESSIONS = 1e3;
5430
5635
  var RATE_LIMIT_WINDOW_MS = 6e4;
5431
5636
  var RATE_LIMIT_GENERAL = 120;
@@ -5440,8 +5645,11 @@ var DashboardApprovalChannel = class {
5440
5645
  baseline = null;
5441
5646
  auditLog = null;
5442
5647
  dashboardHTML;
5648
+ loginHTML;
5443
5649
  authToken;
5444
5650
  useTLS;
5651
+ /** Session TTL: longer for localhost, shorter for remote */
5652
+ sessionTTLMs;
5445
5653
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5446
5654
  sessions = /* @__PURE__ */ new Map();
5447
5655
  sessionCleanupTimer = null;
@@ -5451,11 +5659,14 @@ var DashboardApprovalChannel = class {
5451
5659
  this.config = config;
5452
5660
  this.authToken = config.auth_token;
5453
5661
  this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
5662
+ const isLocalhost = config.host === "127.0.0.1" || config.host === "localhost" || config.host === "::1";
5663
+ this.sessionTTLMs = isLocalhost ? SESSION_TTL_LOCAL_MS : SESSION_TTL_REMOTE_MS;
5454
5664
  this.dashboardHTML = generateDashboardHTML({
5455
5665
  timeoutSeconds: config.timeout_seconds,
5456
5666
  serverVersion: SANCTUARY_VERSION,
5457
5667
  authToken: this.authToken
5458
5668
  });
5669
+ this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
5459
5670
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5460
5671
  }
5461
5672
  /**
@@ -5485,25 +5696,26 @@ var DashboardApprovalChannel = class {
5485
5696
  const protocol = this.useTLS ? "https" : "http";
5486
5697
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5487
5698
  this.httpServer.listen(this.config.port, this.config.host, () => {
5488
- if (this.authToken) {
5489
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5490
- process.stderr.write(
5491
- `
5699
+ process.stderr.write(
5700
+ `
5492
5701
  Sanctuary Principal Dashboard: ${baseUrl}
5493
5702
  `
5494
- );
5703
+ );
5704
+ if (this.authToken) {
5705
+ const sessionUrl = this.createSessionUrl();
5495
5706
  process.stderr.write(
5496
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5497
-
5707
+ ` Quick open: ${sessionUrl}
5498
5708
  `
5499
5709
  );
5500
- } else {
5710
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5501
5711
  process.stderr.write(
5502
- `
5503
- Sanctuary Principal Dashboard: ${baseUrl}
5712
+ ` Auth token: ${hint}
5504
5713
 
5505
5714
  `
5506
5715
  );
5716
+ } else {
5717
+ process.stderr.write(`
5718
+ `);
5507
5719
  }
5508
5720
  resolve();
5509
5721
  });
@@ -5607,10 +5819,47 @@ var DashboardApprovalChannel = class {
5607
5819
  if (sessionId && this.validateSession(sessionId)) {
5608
5820
  return true;
5609
5821
  }
5822
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5823
+ if (cookieSession && this.validateSession(cookieSession)) {
5824
+ return true;
5825
+ }
5610
5826
  res.writeHead(401, { "Content-Type": "application/json" });
5611
5827
  res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
5612
5828
  return false;
5613
5829
  }
5830
+ /**
5831
+ * Check if a request is authenticated WITHOUT sending a response.
5832
+ * Used to decide between login page vs dashboard for GET /.
5833
+ */
5834
+ isAuthenticated(req, url) {
5835
+ if (!this.authToken) return true;
5836
+ const authHeader = req.headers.authorization;
5837
+ if (authHeader) {
5838
+ const parts = authHeader.split(" ");
5839
+ if (parts.length === 2 && parts[0] === "Bearer" && parts[1] === this.authToken) {
5840
+ return true;
5841
+ }
5842
+ }
5843
+ const sessionId = url.searchParams.get("session");
5844
+ if (sessionId && this.validateSession(sessionId)) return true;
5845
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5846
+ if (cookieSession && this.validateSession(cookieSession)) return true;
5847
+ return false;
5848
+ }
5849
+ /**
5850
+ * Parse a specific cookie value from the request.
5851
+ */
5852
+ parseCookie(req, name) {
5853
+ const header = req.headers.cookie;
5854
+ if (!header) return null;
5855
+ for (const part of header.split(";")) {
5856
+ const [key, ...rest] = part.split("=");
5857
+ if (key?.trim() === name) {
5858
+ return rest.join("=").trim();
5859
+ }
5860
+ }
5861
+ return null;
5862
+ }
5614
5863
  // ── Session Management (SEC-012) ──────────────────────────────────
5615
5864
  /**
5616
5865
  * Create a short-lived session by exchanging the long-lived auth token
@@ -5631,7 +5880,7 @@ var DashboardApprovalChannel = class {
5631
5880
  this.sessions.set(id, {
5632
5881
  id,
5633
5882
  created_at: now,
5634
- expires_at: now + SESSION_TTL_MS
5883
+ expires_at: now + this.sessionTTLMs
5635
5884
  });
5636
5885
  return id;
5637
5886
  }
@@ -5730,13 +5979,26 @@ var DashboardApprovalChannel = class {
5730
5979
  res.end();
5731
5980
  return;
5732
5981
  }
5733
- if (!this.checkAuth(req, url, res)) return;
5734
- if (!this.checkRateLimit(req, res, "general")) return;
5735
- try {
5736
- if (method === "POST" && url.pathname === "/auth/session") {
5982
+ if (method === "POST" && url.pathname === "/auth/session") {
5983
+ if (!this.checkRateLimit(req, res, "general")) return;
5984
+ try {
5737
5985
  this.handleSessionExchange(req, res);
5986
+ } catch {
5987
+ res.writeHead(500, { "Content-Type": "application/json" });
5988
+ res.end(JSON.stringify({ error: "Internal server error" }));
5989
+ }
5990
+ return;
5991
+ }
5992
+ if (method === "GET" && url.pathname === "/" && this.authToken) {
5993
+ if (!this.isAuthenticated(req, url)) {
5994
+ if (!this.checkRateLimit(req, res, "general")) return;
5995
+ this.serveLoginPage(res);
5738
5996
  return;
5739
5997
  }
5998
+ }
5999
+ if (!this.checkAuth(req, url, res)) return;
6000
+ if (!this.checkRateLimit(req, res, "general")) return;
6001
+ try {
5740
6002
  if (method === "GET" && url.pathname === "/") {
5741
6003
  this.serveDashboard(res);
5742
6004
  } else if (method === "GET" && url.pathname === "/events") {
@@ -5793,12 +6055,23 @@ var DashboardApprovalChannel = class {
5793
6055
  return;
5794
6056
  }
5795
6057
  const sessionId = this.createSession();
5796
- res.writeHead(200, { "Content-Type": "application/json" });
6058
+ const ttlSeconds = Math.floor(this.sessionTTLMs / 1e3);
6059
+ res.writeHead(200, {
6060
+ "Content-Type": "application/json",
6061
+ "Set-Cookie": `sanctuary_session=${sessionId}; Path=/; SameSite=Strict; Max-Age=${ttlSeconds}`
6062
+ });
5797
6063
  res.end(JSON.stringify({
5798
6064
  session_id: sessionId,
5799
- expires_in_seconds: SESSION_TTL_MS / 1e3
6065
+ expires_in_seconds: ttlSeconds
5800
6066
  }));
5801
6067
  }
6068
+ serveLoginPage(res) {
6069
+ res.writeHead(200, {
6070
+ "Content-Type": "text/html; charset=utf-8",
6071
+ "Cache-Control": "no-cache, no-store"
6072
+ });
6073
+ res.end(this.loginHTML);
6074
+ }
5802
6075
  serveDashboard(res) {
5803
6076
  res.writeHead(200, {
5804
6077
  "Content-Type": "text/html; charset=utf-8",
@@ -5974,6 +6247,22 @@ data: ${JSON.stringify(data)}
5974
6247
  broadcastProtectionStatus(data) {
5975
6248
  this.broadcastSSE("protection-status", data);
5976
6249
  }
6250
+ /**
6251
+ * Create a pre-authenticated URL for the dashboard.
6252
+ * Used by the sanctuary_dashboard_open tool and at startup.
6253
+ */
6254
+ createSessionUrl() {
6255
+ const sessionId = this.createSession();
6256
+ const protocol = this.useTLS ? "https" : "http";
6257
+ return `${protocol}://${this.config.host}:${this.config.port}/?session=${sessionId}`;
6258
+ }
6259
+ /**
6260
+ * Get the base URL for the dashboard.
6261
+ */
6262
+ getBaseUrl() {
6263
+ const protocol = this.useTLS ? "https" : "http";
6264
+ return `${protocol}://${this.config.host}:${this.config.port}`;
6265
+ }
5977
6266
  /** Get the number of pending requests */
5978
6267
  get pendingCount() {
5979
6268
  return this.pending.size;
@@ -11788,6 +12077,32 @@ async function createSanctuaryServer(options) {
11788
12077
  } : void 0;
11789
12078
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
11790
12079
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
12080
+ const dashboardTools = [];
12081
+ if (dashboard) {
12082
+ dashboardTools.push({
12083
+ name: "sanctuary/dashboard_open",
12084
+ 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.",
12085
+ inputSchema: {
12086
+ type: "object",
12087
+ properties: {}
12088
+ },
12089
+ handler: async () => {
12090
+ const url = dashboard.createSessionUrl();
12091
+ return {
12092
+ content: [
12093
+ {
12094
+ type: "text",
12095
+ text: JSON.stringify({
12096
+ dashboard_url: url,
12097
+ base_url: dashboard.getBaseUrl(),
12098
+ note: "Click the dashboard_url to open the Principal Dashboard. The session is pre-authenticated."
12099
+ }, null, 2)
12100
+ }
12101
+ ]
12102
+ };
12103
+ }
12104
+ });
12105
+ }
11791
12106
  let allTools = [
11792
12107
  ...l1Tools,
11793
12108
  ...l2Tools,
@@ -11801,6 +12116,7 @@ async function createSanctuaryServer(options) {
11801
12116
  ...auditTools,
11802
12117
  ...contextGateTools,
11803
12118
  ...hardeningTools,
12119
+ ...dashboardTools,
11804
12120
  manifestTool
11805
12121
  ];
11806
12122
  allTools = allTools.map((tool) => ({