@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.cjs CHANGED
@@ -4042,6 +4042,181 @@ var StderrApprovalChannel = class {
4042
4042
  };
4043
4043
 
4044
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
+ }
4045
4220
  function generateDashboardHTML(options) {
4046
4221
  return `<!DOCTYPE html>
4047
4222
  <html lang="en">
@@ -4911,7 +5086,9 @@ function generateDashboardHTML(options) {
4911
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
4912
5087
 
4913
5088
  const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4914
- 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; } })();
4915
5092
  const MAX_ACTIVITY_ITEMS = 100;
4916
5093
  const MAX_THREAT_ITEMS = 20;
4917
5094
 
@@ -4926,6 +5103,7 @@ function generateDashboardHTML(options) {
4926
5103
  const activityItems = [];
4927
5104
  const threatItems = [];
4928
5105
  let sovereigntyScore = 85;
5106
+ let sessionRenewalTimer = null;
4929
5107
 
4930
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
4931
5109
 
@@ -4941,6 +5119,11 @@ function generateDashboardHTML(options) {
4941
5119
  return url + sep + 'session=' + SESSION_ID;
4942
5120
  }
4943
5121
 
5122
+ function setCookie(sessionId, maxAge) {
5123
+ document.cookie = 'sanctuary_session=' + sessionId +
5124
+ '; path=/; SameSite=Strict; max-age=' + maxAge;
5125
+ }
5126
+
4944
5127
  async function exchangeSession() {
4945
5128
  if (!AUTH_TOKEN) return;
4946
5129
  try {
@@ -4948,14 +5131,35 @@ function generateDashboardHTML(options) {
4948
5131
  if (resp.ok) {
4949
5132
  const data = await resp.json();
4950
5133
  SESSION_ID = data.session_id;
4951
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4952
- 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();
4953
5145
  }
4954
5146
  } catch (e) {
4955
- // 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);
4956
5152
  }
4957
5153
  }
4958
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
+
4959
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
4960
5164
 
4961
5165
  function esc(s) {
@@ -5428,7 +5632,8 @@ function generateDashboardHTML(options) {
5428
5632
  }
5429
5633
 
5430
5634
  // src/principal-policy/dashboard.ts
5431
- 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;
5432
5637
  var MAX_SESSIONS = 1e3;
5433
5638
  var RATE_LIMIT_WINDOW_MS = 6e4;
5434
5639
  var RATE_LIMIT_GENERAL = 120;
@@ -5443,8 +5648,11 @@ var DashboardApprovalChannel = class {
5443
5648
  baseline = null;
5444
5649
  auditLog = null;
5445
5650
  dashboardHTML;
5651
+ loginHTML;
5446
5652
  authToken;
5447
5653
  useTLS;
5654
+ /** Session TTL: longer for localhost, shorter for remote */
5655
+ sessionTTLMs;
5448
5656
  /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5449
5657
  sessions = /* @__PURE__ */ new Map();
5450
5658
  sessionCleanupTimer = null;
@@ -5454,11 +5662,14 @@ var DashboardApprovalChannel = class {
5454
5662
  this.config = config;
5455
5663
  this.authToken = config.auth_token;
5456
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;
5457
5667
  this.dashboardHTML = generateDashboardHTML({
5458
5668
  timeoutSeconds: config.timeout_seconds,
5459
5669
  serverVersion: SANCTUARY_VERSION,
5460
5670
  authToken: this.authToken
5461
5671
  });
5672
+ this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
5462
5673
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5463
5674
  }
5464
5675
  /**
@@ -5488,25 +5699,26 @@ var DashboardApprovalChannel = class {
5488
5699
  const protocol = this.useTLS ? "https" : "http";
5489
5700
  const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5490
5701
  this.httpServer.listen(this.config.port, this.config.host, () => {
5491
- if (this.authToken) {
5492
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5493
- process.stderr.write(
5494
- `
5702
+ process.stderr.write(
5703
+ `
5495
5704
  Sanctuary Principal Dashboard: ${baseUrl}
5496
5705
  `
5497
- );
5706
+ );
5707
+ if (this.authToken) {
5708
+ const sessionUrl = this.createSessionUrl();
5498
5709
  process.stderr.write(
5499
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5500
-
5710
+ ` Quick open: ${sessionUrl}
5501
5711
  `
5502
5712
  );
5503
- } else {
5713
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5504
5714
  process.stderr.write(
5505
- `
5506
- Sanctuary Principal Dashboard: ${baseUrl}
5715
+ ` Auth token: ${hint}
5507
5716
 
5508
5717
  `
5509
5718
  );
5719
+ } else {
5720
+ process.stderr.write(`
5721
+ `);
5510
5722
  }
5511
5723
  resolve();
5512
5724
  });
@@ -5610,10 +5822,47 @@ var DashboardApprovalChannel = class {
5610
5822
  if (sessionId && this.validateSession(sessionId)) {
5611
5823
  return true;
5612
5824
  }
5825
+ const cookieSession = this.parseCookie(req, "sanctuary_session");
5826
+ if (cookieSession && this.validateSession(cookieSession)) {
5827
+ return true;
5828
+ }
5613
5829
  res.writeHead(401, { "Content-Type": "application/json" });
5614
5830
  res.end(JSON.stringify({ error: "Unauthorized \u2014 use Authorization: Bearer header or a valid session" }));
5615
5831
  return false;
5616
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
+ }
5617
5866
  // ── Session Management (SEC-012) ──────────────────────────────────
5618
5867
  /**
5619
5868
  * Create a short-lived session by exchanging the long-lived auth token
@@ -5634,7 +5883,7 @@ var DashboardApprovalChannel = class {
5634
5883
  this.sessions.set(id, {
5635
5884
  id,
5636
5885
  created_at: now,
5637
- expires_at: now + SESSION_TTL_MS
5886
+ expires_at: now + this.sessionTTLMs
5638
5887
  });
5639
5888
  return id;
5640
5889
  }
@@ -5733,13 +5982,26 @@ var DashboardApprovalChannel = class {
5733
5982
  res.end();
5734
5983
  return;
5735
5984
  }
5736
- if (!this.checkAuth(req, url, res)) return;
5737
- if (!this.checkRateLimit(req, res, "general")) return;
5738
- try {
5739
- 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 {
5740
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);
5741
5999
  return;
5742
6000
  }
6001
+ }
6002
+ if (!this.checkAuth(req, url, res)) return;
6003
+ if (!this.checkRateLimit(req, res, "general")) return;
6004
+ try {
5743
6005
  if (method === "GET" && url.pathname === "/") {
5744
6006
  this.serveDashboard(res);
5745
6007
  } else if (method === "GET" && url.pathname === "/events") {
@@ -5796,12 +6058,23 @@ var DashboardApprovalChannel = class {
5796
6058
  return;
5797
6059
  }
5798
6060
  const sessionId = this.createSession();
5799
- 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
+ });
5800
6066
  res.end(JSON.stringify({
5801
6067
  session_id: sessionId,
5802
- expires_in_seconds: SESSION_TTL_MS / 1e3
6068
+ expires_in_seconds: ttlSeconds
5803
6069
  }));
5804
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
+ }
5805
6078
  serveDashboard(res) {
5806
6079
  res.writeHead(200, {
5807
6080
  "Content-Type": "text/html; charset=utf-8",
@@ -5977,6 +6250,22 @@ data: ${JSON.stringify(data)}
5977
6250
  broadcastProtectionStatus(data) {
5978
6251
  this.broadcastSSE("protection-status", data);
5979
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
+ }
5980
6269
  /** Get the number of pending requests */
5981
6270
  get pendingCount() {
5982
6271
  return this.pending.size;
@@ -11791,6 +12080,32 @@ async function createSanctuaryServer(options) {
11791
12080
  } : void 0;
11792
12081
  const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
11793
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
+ }
11794
12109
  let allTools = [
11795
12110
  ...l1Tools,
11796
12111
  ...l2Tools,
@@ -11804,6 +12119,7 @@ async function createSanctuaryServer(options) {
11804
12119
  ...auditTools,
11805
12120
  ...contextGateTools,
11806
12121
  ...hardeningTools,
12122
+ ...dashboardTools,
11807
12123
  manifestTool
11808
12124
  ];
11809
12125
  allTools = allTools.map((tool) => ({