@sanctuary-framework/mcp-server 0.4.2 → 0.5.1

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.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");
@@ -4055,648 +4062,1478 @@ function generateDashboardHTML(options) {
4055
4062
  <meta charset="utf-8">
4056
4063
  <meta name="viewport" content="width=device-width, initial-scale=1">
4057
4064
  <title>Sanctuary \u2014 Principal Dashboard</title>
4065
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
4058
4066
  <style>
4059
4067
  :root {
4060
- --bg: #0f1117;
4061
- --bg-surface: #1a1d27;
4062
- --bg-elevated: #242736;
4063
- --border: #2e3244;
4064
- --text: #e4e6f0;
4065
- --text-muted: #8b8fa3;
4066
- --accent: #6c8aff;
4067
- --accent-hover: #839dff;
4068
- --approve: #3ecf8e;
4069
- --approve-hover: #5dd9a3;
4070
- --deny: #f87171;
4071
- --deny-hover: #fca5a5;
4072
- --warning: #fbbf24;
4073
- --tier1: #f87171;
4074
- --tier2: #fbbf24;
4075
- --tier3: #3ecf8e;
4076
- --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
4077
- --mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
4078
- --radius: 8px;
4079
- }
4080
-
4081
- * { box-sizing: border-box; margin: 0; padding: 0; }
4068
+ --bg: #0d1117;
4069
+ --surface: #161b22;
4070
+ --border: #30363d;
4071
+ --text-primary: #e6edf3;
4072
+ --text-secondary: #8b949e;
4073
+ --green: #3fb950;
4074
+ --amber: #d29922;
4075
+ --red: #f85149;
4076
+ --blue: #58a6ff;
4077
+ --mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
4078
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
4079
+ --radius: 6px;
4080
+ }
4081
+
4082
+ * {
4083
+ box-sizing: border-box;
4084
+ margin: 0;
4085
+ padding: 0;
4086
+ }
4087
+
4088
+ html, body {
4089
+ width: 100%;
4090
+ height: 100%;
4091
+ overflow: hidden;
4092
+ }
4093
+
4082
4094
  body {
4083
- font-family: var(--font);
4095
+ font-family: var(--sans);
4084
4096
  background: var(--bg);
4085
- color: var(--text);
4086
- line-height: 1.5;
4087
- min-height: 100vh;
4097
+ color: var(--text-primary);
4098
+ display: flex;
4099
+ flex-direction: column;
4088
4100
  }
4089
4101
 
4090
- /* Layout */
4091
- .container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
4102
+ /* \u2500\u2500 Top Status Bar (fixed) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4092
4103
 
4093
- header {
4094
- display: flex; align-items: center; justify-content: space-between;
4095
- padding-bottom: 20px; border-bottom: 1px solid var(--border);
4096
- margin-bottom: 24px;
4104
+ .status-bar {
4105
+ position: fixed;
4106
+ top: 0;
4107
+ left: 0;
4108
+ right: 0;
4109
+ height: 56px;
4110
+ background: var(--surface);
4111
+ border-bottom: 1px solid var(--border);
4112
+ display: flex;
4113
+ align-items: center;
4114
+ padding: 0 20px;
4115
+ gap: 24px;
4116
+ z-index: 1000;
4097
4117
  }
4098
- header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
4099
- header h1 span { color: var(--accent); }
4100
- .status-badge {
4101
- display: inline-flex; align-items: center; gap: 6px;
4102
- font-size: 12px; color: var(--text-muted);
4103
- padding: 4px 10px; border-radius: 12px;
4104
- background: var(--bg-surface); border: 1px solid var(--border);
4118
+
4119
+ .status-bar-left {
4120
+ display: flex;
4121
+ align-items: center;
4122
+ gap: 12px;
4123
+ flex: 0 0 auto;
4105
4124
  }
4106
- .status-dot {
4107
- width: 8px; height: 8px; border-radius: 50%;
4108
- background: var(--approve); animation: pulse 2s infinite;
4109
- }
4110
- .status-dot.disconnected { background: var(--deny); animation: none; }
4111
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
4112
-
4113
- /* Tabs */
4114
- .tabs {
4115
- display: flex; gap: 2px; margin-bottom: 20px;
4116
- background: var(--bg-surface); border-radius: var(--radius);
4117
- padding: 3px; border: 1px solid var(--border);
4118
- }
4119
- .tab {
4120
- flex: 1; padding: 8px 12px; text-align: center;
4121
- font-size: 13px; font-weight: 500; cursor: pointer;
4122
- border-radius: 6px; border: none; color: var(--text-muted);
4123
- background: transparent; transition: all 0.15s;
4124
- }
4125
- .tab:hover { color: var(--text); }
4126
- .tab.active { background: var(--bg-elevated); color: var(--text); }
4127
- .tab .count {
4128
- display: inline-flex; align-items: center; justify-content: center;
4129
- min-width: 18px; height: 18px; padding: 0 5px;
4130
- font-size: 11px; font-weight: 600; border-radius: 9px;
4131
- margin-left: 6px;
4132
- }
4133
- .tab .count.alert { background: var(--deny); color: white; }
4134
- .tab .count.muted { background: var(--border); color: var(--text-muted); }
4135
-
4136
- /* Tab Content */
4137
- .tab-content { display: none; }
4138
- .tab-content.active { display: block; }
4139
-
4140
- /* Pending Requests */
4141
- .pending-empty {
4142
- text-align: center; padding: 60px 20px; color: var(--text-muted);
4143
- }
4144
- .pending-empty .icon { font-size: 32px; margin-bottom: 12px; }
4145
- .pending-empty p { font-size: 14px; }
4146
-
4147
- .request-card {
4148
- background: var(--bg-surface); border: 1px solid var(--border);
4149
- border-radius: var(--radius); padding: 16px; margin-bottom: 12px;
4150
- animation: slideIn 0.2s ease-out;
4151
- }
4152
- @keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
4153
- .request-card.tier1 { border-left: 3px solid var(--tier1); }
4154
- .request-card.tier2 { border-left: 3px solid var(--tier2); }
4155
- .request-header {
4156
- display: flex; align-items: center; justify-content: space-between;
4157
- margin-bottom: 10px;
4158
- }
4159
- .request-op {
4160
- font-family: var(--mono); font-size: 14px; font-weight: 600;
4161
- }
4162
- .tier-badge {
4163
- font-size: 11px; font-weight: 600; padding: 2px 8px;
4164
- border-radius: 4px; text-transform: uppercase;
4165
- }
4166
- .tier-badge.tier1 { background: rgba(248,113,113,0.15); color: var(--tier1); }
4167
- .tier-badge.tier2 { background: rgba(251,191,36,0.15); color: var(--tier2); }
4168
- .request-reason {
4169
- font-size: 13px; color: var(--text-muted); margin-bottom: 12px;
4170
- }
4171
- .request-context {
4172
- font-family: var(--mono); font-size: 12px; color: var(--text-muted);
4173
- background: var(--bg); border-radius: 4px; padding: 8px 10px;
4174
- margin-bottom: 14px; white-space: pre-wrap; word-break: break-all;
4175
- max-height: 120px; overflow-y: auto;
4176
- }
4177
- .request-actions {
4178
- display: flex; align-items: center; gap: 10px;
4125
+
4126
+ .sanctuary-logo {
4127
+ font-weight: 700;
4128
+ font-size: 16px;
4129
+ letter-spacing: -0.5px;
4130
+ color: var(--text-primary);
4179
4131
  }
4180
- .btn {
4181
- padding: 7px 16px; border-radius: 6px; font-size: 13px;
4182
- font-weight: 600; border: none; cursor: pointer;
4183
- transition: all 0.15s;
4132
+
4133
+ .sanctuary-logo span {
4134
+ color: var(--blue);
4184
4135
  }
4185
- .btn-approve { background: var(--approve); color: #0f1117; }
4186
- .btn-approve:hover { background: var(--approve-hover); }
4187
- .btn-deny { background: var(--deny); color: white; }
4188
- .btn-deny:hover { background: var(--deny-hover); }
4189
- .countdown {
4190
- margin-left: auto; font-size: 12px; color: var(--text-muted);
4136
+
4137
+ .version {
4138
+ font-size: 11px;
4139
+ color: var(--text-secondary);
4191
4140
  font-family: var(--mono);
4192
4141
  }
4193
- .countdown.urgent { color: var(--deny); font-weight: 600; }
4194
4142
 
4195
- /* Audit Log */
4196
- .audit-table { width: 100%; border-collapse: collapse; }
4197
- .audit-table th {
4198
- text-align: left; font-size: 11px; font-weight: 600;
4199
- text-transform: uppercase; letter-spacing: 0.5px;
4200
- color: var(--text-muted); padding: 8px 10px;
4201
- border-bottom: 1px solid var(--border);
4143
+ .status-bar-center {
4144
+ flex: 1;
4145
+ display: flex;
4146
+ align-items: center;
4147
+ justify-content: center;
4202
4148
  }
4203
- .audit-table td {
4204
- font-size: 13px; padding: 8px 10px;
4205
- border-bottom: 1px solid var(--border);
4149
+
4150
+ .sovereignty-badge {
4151
+ display: flex;
4152
+ align-items: center;
4153
+ gap: 8px;
4154
+ padding: 6px 12px;
4155
+ background: rgba(88, 166, 255, 0.1);
4156
+ border: 1px solid var(--blue);
4157
+ border-radius: 20px;
4158
+ font-size: 13px;
4159
+ font-weight: 600;
4206
4160
  }
4207
- .audit-table tr { transition: background 0.1s; }
4208
- .audit-table tr:hover { background: var(--bg-elevated); }
4209
- .audit-table tr.new { animation: highlight 1s ease-out; }
4210
- @keyframes highlight { from { background: rgba(108,138,255,0.15); } to { background: transparent; } }
4211
- .audit-time { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
4212
- .audit-op { font-family: var(--mono); font-size: 12px; }
4213
- .audit-layer {
4214
- font-size: 11px; font-weight: 600; padding: 1px 6px;
4215
- border-radius: 3px; text-transform: uppercase;
4216
- }
4217
- .audit-layer.l1 { background: rgba(108,138,255,0.15); color: var(--accent); }
4218
- .audit-layer.l2 { background: rgba(251,191,36,0.15); color: var(--tier2); }
4219
- .audit-layer.l3 { background: rgba(62,207,142,0.15); color: var(--tier3); }
4220
- .audit-layer.l4 { background: rgba(168,85,247,0.15); color: #a855f7; }
4221
-
4222
- /* Baseline & Policy */
4223
- .info-section {
4224
- background: var(--bg-surface); border: 1px solid var(--border);
4225
- border-radius: var(--radius); padding: 16px; margin-bottom: 16px;
4226
- }
4227
- .info-section h3 {
4228
- font-size: 13px; font-weight: 600; text-transform: uppercase;
4229
- letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 12px;
4230
- }
4231
- .info-row {
4232
- display: flex; justify-content: space-between; align-items: center;
4233
- padding: 6px 0; font-size: 13px;
4234
- }
4235
- .info-label { color: var(--text-muted); }
4236
- .info-value { font-family: var(--mono); font-size: 12px; }
4237
- .tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
4238
- .tag {
4239
- font-family: var(--mono); font-size: 11px; padding: 2px 8px;
4240
- background: var(--bg-elevated); border-radius: 4px;
4241
- color: var(--text-muted); border: 1px solid var(--border);
4242
- }
4243
- .policy-op {
4244
- font-family: var(--mono); font-size: 12px; padding: 3px 0;
4245
- }
4246
-
4247
- /* Footer */
4248
- footer {
4249
- margin-top: 32px; padding-top: 16px;
4250
- border-top: 1px solid var(--border);
4251
- font-size: 12px; color: var(--text-muted);
4252
- text-align: center;
4161
+
4162
+ .sovereignty-score {
4163
+ display: flex;
4164
+ align-items: center;
4165
+ justify-content: center;
4166
+ width: 28px;
4167
+ height: 28px;
4168
+ border-radius: 50%;
4169
+ font-family: var(--mono);
4170
+ font-weight: 700;
4171
+ font-size: 12px;
4172
+ background: var(--blue);
4173
+ color: var(--bg);
4253
4174
  }
4254
- </style>
4255
- </head>
4256
- <body>
4257
- <div class="container">
4258
- <header>
4259
- <h1><span>Sanctuary</span> Principal Dashboard</h1>
4260
- <div class="status-badge">
4261
- <div class="status-dot" id="statusDot"></div>
4262
- <span id="statusText">Connected</span>
4263
- </div>
4264
- </header>
4265
-
4266
- <div class="tabs">
4267
- <button class="tab active" data-tab="pending">
4268
- Pending<span class="count muted" id="pendingCount">0</span>
4269
- </button>
4270
- <button class="tab" data-tab="audit">
4271
- Audit Log<span class="count muted" id="auditCount">0</span>
4272
- </button>
4273
- <button class="tab" data-tab="baseline">Baseline</button>
4274
- <button class="tab" data-tab="policy">Policy</button>
4275
- </div>
4276
4175
 
4277
- <!-- Pending Approvals -->
4278
- <div class="tab-content active" id="tab-pending">
4279
- <div class="pending-empty" id="pendingEmpty">
4280
- <div class="icon">&#x2714;</div>
4281
- <p>No pending approval requests.</p>
4282
- <p style="font-size:12px; margin-top:4px;">Requests will appear here in real time.</p>
4283
- </div>
4284
- <div id="pendingList"></div>
4285
- </div>
4176
+ .sovereignty-score.high {
4177
+ background: var(--green);
4178
+ }
4286
4179
 
4287
- <!-- Audit Log -->
4288
- <div class="tab-content" id="tab-audit">
4289
- <table class="audit-table">
4290
- <thead>
4291
- <tr><th>Time</th><th>Layer</th><th>Operation</th><th>Identity</th></tr>
4292
- </thead>
4293
- <tbody id="auditBody"></tbody>
4294
- </table>
4295
- </div>
4180
+ .sovereignty-score.medium {
4181
+ background: var(--amber);
4182
+ }
4296
4183
 
4297
- <!-- Baseline -->
4298
- <div class="tab-content" id="tab-baseline">
4299
- <div class="info-section">
4300
- <h3>Session Info</h3>
4301
- <div class="info-row"><span class="info-label">First session</span><span class="info-value" id="bFirstSession">\u2014</span></div>
4302
- <div class="info-row"><span class="info-label">Started</span><span class="info-value" id="bStarted">\u2014</span></div>
4303
- </div>
4304
- <div class="info-section">
4305
- <h3>Known Namespaces</h3>
4306
- <div class="tag-list" id="bNamespaces"><span class="tag">\u2014</span></div>
4307
- </div>
4308
- <div class="info-section">
4309
- <h3>Known Counterparties</h3>
4310
- <div class="tag-list" id="bCounterparties"><span class="tag">\u2014</span></div>
4311
- </div>
4312
- <div class="info-section">
4313
- <h3>Tool Call Counts</h3>
4314
- <div id="bToolCalls"><span class="info-value">\u2014</span></div>
4315
- </div>
4316
- </div>
4184
+ .sovereignty-score.low {
4185
+ background: var(--red);
4186
+ }
4317
4187
 
4318
- <!-- Policy -->
4319
- <div class="tab-content" id="tab-policy">
4320
- <div class="info-section">
4321
- <h3>Tier 1 \u2014 Always Requires Approval</h3>
4322
- <div id="pTier1"></div>
4323
- </div>
4324
- <div class="info-section">
4325
- <h3>Tier 2 \u2014 Anomaly Detection</h3>
4326
- <div id="pTier2"></div>
4327
- </div>
4328
- <div class="info-section">
4329
- <h3>Tier 3 \u2014 Always Allowed</h3>
4330
- <div class="info-row">
4331
- <span class="info-label">Operations</span>
4332
- <span class="info-value" id="pTier3Count">\u2014</span>
4333
- </div>
4334
- </div>
4335
- <div class="info-section">
4336
- <h3>Approval Channel</h3>
4337
- <div id="pChannel"></div>
4338
- </div>
4339
- </div>
4188
+ .status-bar-right {
4189
+ display: flex;
4190
+ align-items: center;
4191
+ gap: 16px;
4192
+ flex: 0 0 auto;
4193
+ }
4340
4194
 
4341
- <footer>Sanctuary Framework v${options.serverVersion} \u2014 Principal Dashboard</footer>
4342
- </div>
4195
+ .protections-indicator {
4196
+ display: flex;
4197
+ align-items: center;
4198
+ gap: 6px;
4199
+ font-size: 12px;
4200
+ color: var(--text-secondary);
4201
+ font-family: var(--mono);
4202
+ }
4343
4203
 
4344
- <script>
4345
- (function() {
4346
- const TIMEOUT = ${options.timeoutSeconds};
4347
- // SEC-012: Auth token is passed via Authorization header only \u2014 never in URLs.
4348
- // The token is provided by the server at generation time (embedded for initial auth).
4349
- const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
4350
- let SESSION_ID = null; // Short-lived session for SSE and URL-based requests
4351
- const pending = new Map();
4352
- let auditCount = 0;
4204
+ .protections-indicator .count {
4205
+ color: var(--text-primary);
4206
+ font-weight: 600;
4207
+ }
4353
4208
 
4354
- // Auth helpers \u2014 SEC-012: token goes in header, session goes in URL
4355
- function authHeaders() {
4356
- const h = { 'Content-Type': 'application/json' };
4357
- if (AUTH_TOKEN) h['Authorization'] = 'Bearer ' + AUTH_TOKEN;
4358
- return h;
4209
+ .uptime {
4210
+ display: flex;
4211
+ align-items: center;
4212
+ gap: 6px;
4213
+ font-size: 12px;
4214
+ color: var(--text-secondary);
4215
+ font-family: var(--mono);
4359
4216
  }
4360
- function sessionQuery(url) {
4361
- if (!SESSION_ID) return url;
4362
- const sep = url.includes('?') ? '&' : '?';
4363
- return url + sep + 'session=' + SESSION_ID;
4217
+
4218
+ .status-dot {
4219
+ width: 8px;
4220
+ height: 8px;
4221
+ border-radius: 50%;
4222
+ background: var(--green);
4223
+ animation: pulse 2s ease-in-out infinite;
4364
4224
  }
4365
4225
 
4366
- // SEC-012: Exchange the long-lived token for a short-lived session
4367
- async function exchangeSession() {
4368
- if (!AUTH_TOKEN) return;
4369
- try {
4370
- const resp = await fetch('/auth/session', { method: 'POST', headers: authHeaders() });
4371
- if (resp.ok) {
4372
- const data = await resp.json();
4373
- SESSION_ID = data.session_id;
4374
- // Refresh session before expiry (at 80% of TTL)
4375
- const refreshMs = (data.expires_in_seconds || 300) * 800;
4376
- setTimeout(async () => { await exchangeSession(); reconnectSSE(); }, refreshMs);
4377
- }
4378
- } catch(e) { /* will retry on next connect */ }
4226
+ .status-dot.disconnected {
4227
+ background: var(--red);
4228
+ animation: none;
4379
4229
  }
4380
4230
 
4381
- // Tab switching
4382
- document.querySelectorAll('.tab').forEach(tab => {
4383
- tab.addEventListener('click', () => {
4384
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
4385
- document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
4386
- tab.classList.add('active');
4387
- document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
4388
- });
4389
- });
4231
+ @keyframes pulse {
4232
+ 0%, 100% { opacity: 1; }
4233
+ 50% { opacity: 0.5; }
4234
+ }
4390
4235
 
4391
- // SSE Connection \u2014 SEC-012: uses short-lived session token in URL, not auth token
4392
- let evtSource;
4393
- function reconnectSSE() {
4394
- if (evtSource) { evtSource.close(); }
4395
- connect();
4236
+ .pending-badge {
4237
+ display: inline-flex;
4238
+ align-items: center;
4239
+ justify-content: center;
4240
+ min-width: 24px;
4241
+ height: 24px;
4242
+ padding: 0 6px;
4243
+ background: var(--red);
4244
+ color: white;
4245
+ border-radius: 12px;
4246
+ font-size: 11px;
4247
+ font-weight: 700;
4248
+ animation: pulse 1s ease-in-out infinite;
4396
4249
  }
4397
- function connect() {
4398
- evtSource = new EventSource(sessionQuery('/events'));
4399
- evtSource.onopen = () => {
4400
- document.getElementById('statusDot').classList.remove('disconnected');
4401
- document.getElementById('statusText').textContent = 'Connected';
4402
- };
4403
- evtSource.onerror = () => {
4404
- document.getElementById('statusDot').classList.add('disconnected');
4405
- document.getElementById('statusText').textContent = 'Reconnecting...';
4406
- };
4407
- evtSource.addEventListener('pending-request', (e) => {
4408
- const data = JSON.parse(e.data);
4409
- addPendingRequest(data);
4410
- });
4411
- evtSource.addEventListener('request-resolved', (e) => {
4412
- const data = JSON.parse(e.data);
4413
- removePendingRequest(data.request_id);
4414
- });
4415
- evtSource.addEventListener('audit-entry', (e) => {
4416
- const data = JSON.parse(e.data);
4417
- addAuditEntry(data);
4418
- });
4419
- evtSource.addEventListener('baseline-update', (e) => {
4420
- const data = JSON.parse(e.data);
4421
- updateBaseline(data);
4422
- });
4423
- evtSource.addEventListener('policy-update', (e) => {
4424
- const data = JSON.parse(e.data);
4425
- updatePolicy(data);
4426
- });
4427
- evtSource.addEventListener('init', (e) => {
4428
- const data = JSON.parse(e.data);
4429
- if (data.baseline) updateBaseline(data.baseline);
4430
- if (data.policy) updatePolicy(data.policy);
4431
- if (data.pending) data.pending.forEach(addPendingRequest);
4432
- if (data.audit) data.audit.forEach(addAuditEntry);
4433
- });
4250
+
4251
+ .pending-badge.hidden {
4252
+ display: none;
4434
4253
  }
4435
4254
 
4436
- // Pending requests
4437
- function addPendingRequest(req) {
4438
- pending.set(req.request_id, { ...req, remaining: TIMEOUT });
4439
- renderPending();
4440
- updatePendingCount();
4441
- flashTab('pending');
4255
+ /* \u2500\u2500 Main Layout \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4256
+
4257
+ .main-container {
4258
+ flex: 1;
4259
+ display: flex;
4260
+ margin-top: 56px;
4261
+ overflow: hidden;
4442
4262
  }
4443
4263
 
4444
- function removePendingRequest(id) {
4445
- pending.delete(id);
4446
- renderPending();
4447
- updatePendingCount();
4264
+ .activity-feed {
4265
+ flex: 3;
4266
+ display: flex;
4267
+ flex-direction: column;
4268
+ border-right: 1px solid var(--border);
4269
+ overflow: hidden;
4448
4270
  }
4449
4271
 
4450
- function renderPending() {
4451
- const list = document.getElementById('pendingList');
4452
- const empty = document.getElementById('pendingEmpty');
4453
- if (pending.size === 0) {
4454
- list.innerHTML = '';
4455
- empty.style.display = 'block';
4456
- return;
4457
- }
4458
- empty.style.display = 'none';
4459
- list.innerHTML = '';
4460
- for (const [id, req] of pending) {
4461
- const card = document.createElement('div');
4462
- card.className = 'request-card tier' + req.tier;
4463
- card.id = 'req-' + id;
4464
- const ctx = typeof req.context === 'string' ? req.context : JSON.stringify(req.context, null, 2);
4465
- card.innerHTML =
4466
- '<div class="request-header">' +
4467
- '<span class="request-op">' + esc(req.operation) + '</span>' +
4468
- '<span class="tier-badge tier' + req.tier + '">Tier ' + req.tier + '</span>' +
4469
- '</div>' +
4470
- '<div class="request-reason">' + esc(req.reason) + '</div>' +
4471
- '<div class="request-context">' + esc(ctx) + '</div>' +
4472
- '<div class="request-actions">' +
4473
- '<button class="btn btn-approve" onclick="handleApprove(\\'' + id + '\\')">Approve</button>' +
4474
- '<button class="btn btn-deny" onclick="handleDeny(\\'' + id + '\\')">Deny</button>' +
4475
- '<span class="countdown" id="cd-' + id + '">' + req.remaining + 's</span>' +
4476
- '</div>';
4477
- list.appendChild(card);
4478
- }
4272
+ .feed-header {
4273
+ padding: 16px 20px;
4274
+ border-bottom: 1px solid var(--border);
4275
+ display: flex;
4276
+ align-items: center;
4277
+ gap: 8px;
4278
+ font-size: 12px;
4279
+ font-weight: 600;
4280
+ text-transform: uppercase;
4281
+ letter-spacing: 0.5px;
4282
+ color: var(--text-secondary);
4479
4283
  }
4480
4284
 
4481
- function updatePendingCount() {
4482
- const el = document.getElementById('pendingCount');
4483
- el.textContent = pending.size;
4484
- el.className = pending.size > 0 ? 'count alert' : 'count muted';
4285
+ .feed-header-dot {
4286
+ width: 6px;
4287
+ height: 6px;
4288
+ border-radius: 50%;
4289
+ background: var(--green);
4485
4290
  }
4486
4291
 
4487
- function flashTab(name) {
4488
- const tab = document.querySelector('[data-tab="' + name + '"]');
4489
- if (!tab.classList.contains('active')) {
4490
- tab.style.background = 'rgba(248,113,113,0.15)';
4491
- setTimeout(() => { tab.style.background = ''; }, 1500);
4492
- }
4292
+ .activity-list {
4293
+ flex: 1;
4294
+ overflow-y: auto;
4295
+ overflow-x: hidden;
4493
4296
  }
4494
4297
 
4495
- // Countdown timer
4496
- setInterval(() => {
4497
- for (const [id, req] of pending) {
4498
- req.remaining = Math.max(0, req.remaining - 1);
4499
- const el = document.getElementById('cd-' + id);
4500
- if (el) {
4501
- el.textContent = req.remaining + 's';
4502
- el.className = req.remaining <= 30 ? 'countdown urgent' : 'countdown';
4503
- }
4504
- }
4505
- }, 1000);
4298
+ .activity-item {
4299
+ padding: 12px 20px;
4300
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
4301
+ font-size: 13px;
4302
+ font-family: var(--mono);
4303
+ cursor: pointer;
4304
+ transition: background 0.15s;
4305
+ display: flex;
4306
+ align-items: flex-start;
4307
+ gap: 10px;
4308
+ }
4506
4309
 
4507
- // Approve / Deny handlers (global scope)
4508
- window.handleApprove = function(id) {
4509
- fetch('/api/approve/' + id, { method: 'POST', headers: authHeaders() }).then(() => {
4510
- removePendingRequest(id);
4511
- });
4512
- };
4513
- window.handleDeny = function(id) {
4514
- fetch('/api/deny/' + id, { method: 'POST', headers: authHeaders() }).then(() => {
4515
- removePendingRequest(id);
4516
- });
4517
- };
4310
+ .activity-item:hover {
4311
+ background: rgba(88, 166, 255, 0.05);
4312
+ }
4518
4313
 
4519
- // Audit log
4520
- function addAuditEntry(entry) {
4521
- auditCount++;
4522
- document.getElementById('auditCount').textContent = auditCount;
4523
- const tbody = document.getElementById('auditBody');
4524
- const tr = document.createElement('tr');
4525
- tr.className = 'new';
4526
- const time = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '\u2014';
4527
- const layer = entry.layer || '\u2014';
4528
- tr.innerHTML =
4529
- '<td class="audit-time">' + esc(time) + '</td>' +
4530
- '<td><span class="audit-layer ' + layer + '">' + esc(layer) + '</span></td>' +
4531
- '<td class="audit-op">' + esc(entry.operation || '\u2014') + '</td>' +
4532
- '<td style="font-size:12px;color:var(--text-muted)">' + esc(entry.identity_id || '\u2014') + '</td>';
4533
- tbody.insertBefore(tr, tbody.firstChild);
4534
- // Keep last 100 entries
4535
- while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild);
4536
- }
4537
-
4538
- // Baseline
4539
- function updateBaseline(b) {
4540
- if (!b) return;
4541
- document.getElementById('bFirstSession').textContent = b.is_first_session ? 'Yes' : 'No';
4542
- document.getElementById('bStarted').textContent = b.started_at ? new Date(b.started_at).toLocaleString() : '\u2014';
4543
- const ns = document.getElementById('bNamespaces');
4544
- ns.innerHTML = (b.known_namespaces || []).length > 0
4545
- ? (b.known_namespaces || []).map(n => '<span class="tag">' + esc(n) + '</span>').join('')
4546
- : '<span class="tag">none</span>';
4547
- const cp = document.getElementById('bCounterparties');
4548
- cp.innerHTML = (b.known_counterparties || []).length > 0
4549
- ? (b.known_counterparties || []).map(c => '<span class="tag">' + esc(c.slice(0,16)) + '...</span>').join('')
4550
- : '<span class="tag">none</span>';
4551
- const tc = document.getElementById('bToolCalls');
4552
- const counts = b.tool_call_counts || {};
4553
- const entries = Object.entries(counts).sort((a,b) => b[1] - a[1]);
4554
- tc.innerHTML = entries.length > 0
4555
- ? entries.map(([k,v]) => '<div class="info-row"><span class="info-label">' + esc(k) + '</span><span class="info-value">' + v + '</span></div>').join('')
4556
- : '<span class="info-value">no calls yet</span>';
4557
- }
4558
-
4559
- // Policy
4560
- function updatePolicy(p) {
4561
- if (!p) return;
4562
- const t1 = document.getElementById('pTier1');
4563
- t1.innerHTML = (p.tier1_always_approve || []).map(op =>
4564
- '<div class="policy-op">' + esc(op) + '</div>'
4565
- ).join('');
4566
- const t2 = document.getElementById('pTier2');
4567
- const cfg = p.tier2_anomaly || {};
4568
- t2.innerHTML = Object.entries(cfg).map(([k,v]) =>
4569
- '<div class="info-row"><span class="info-label">' + esc(k) + '</span><span class="info-value">' + esc(String(v)) + '</span></div>'
4570
- ).join('');
4571
- document.getElementById('pTier3Count').textContent = (p.tier3_always_allow || []).length + ' operations';
4572
- const ch = document.getElementById('pChannel');
4573
- const chan = p.approval_channel || {};
4574
- ch.innerHTML = Object.entries(chan).filter(([k]) => k !== 'webhook_secret').map(([k,v]) =>
4575
- '<div class="info-row"><span class="info-label">' + esc(k) + '</span><span class="info-value">' + esc(String(v)) + '</span></div>'
4576
- ).join('');
4314
+ .activity-item-icon {
4315
+ flex: 0 0 auto;
4316
+ width: 16px;
4317
+ text-align: center;
4318
+ font-size: 12px;
4319
+ color: var(--text-secondary);
4320
+ margin-top: 1px;
4577
4321
  }
4578
4322
 
4579
- function esc(s) {
4580
- if (!s) return '';
4581
- const d = document.createElement('div');
4582
- d.textContent = String(s);
4583
- return d.innerHTML;
4323
+ .activity-item-content {
4324
+ flex: 1;
4325
+ min-width: 0;
4584
4326
  }
4585
4327
 
4586
- // Init \u2014 SEC-012: exchange token for session before connecting SSE
4587
- (async function init() {
4588
- await exchangeSession();
4589
- // Clean token from URL if present (legacy bookmarks)
4590
- if (window.location.search.includes('token=')) {
4591
- const clean = window.location.pathname;
4592
- window.history.replaceState({}, '', clean);
4593
- }
4594
- connect();
4595
- fetch('/api/status', { headers: authHeaders() }).then(r => r.json()).then(data => {
4596
- if (data.baseline) updateBaseline(data.baseline);
4597
- if (data.policy) updatePolicy(data.policy);
4598
- }).catch(() => {});
4599
- })();
4600
- })();
4601
- </script>
4602
- </body>
4603
- </html>`;
4604
- }
4328
+ .activity-time {
4329
+ color: var(--text-secondary);
4330
+ font-size: 11px;
4331
+ margin-bottom: 2px;
4332
+ }
4605
4333
 
4606
- // src/principal-policy/dashboard.ts
4607
- var SESSION_TTL_MS = 5 * 60 * 1e3;
4608
- var MAX_SESSIONS = 1e3;
4609
- var RATE_LIMIT_WINDOW_MS = 6e4;
4610
- var RATE_LIMIT_GENERAL = 120;
4611
- var RATE_LIMIT_DECISIONS = 20;
4612
- var MAX_RATE_LIMIT_ENTRIES = 1e4;
4613
- var DashboardApprovalChannel = class {
4614
- config;
4615
- pending = /* @__PURE__ */ new Map();
4616
- sseClients = /* @__PURE__ */ new Set();
4617
- httpServer = null;
4618
- policy = null;
4619
- baseline = null;
4620
- auditLog = null;
4621
- dashboardHTML;
4622
- authToken;
4623
- useTLS;
4624
- /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
4625
- sessions = /* @__PURE__ */ new Map();
4626
- sessionCleanupTimer = null;
4627
- /** Rate limiting: per-IP request tracking */
4628
- rateLimits = /* @__PURE__ */ new Map();
4629
- constructor(config) {
4630
- this.config = config;
4631
- this.authToken = config.auth_token;
4632
- this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
4633
- this.dashboardHTML = generateDashboardHTML({
4634
- timeoutSeconds: config.timeout_seconds,
4635
- serverVersion: SANCTUARY_VERSION,
4636
- authToken: this.authToken
4637
- });
4638
- this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
4334
+ .activity-main {
4335
+ display: flex;
4336
+ gap: 8px;
4337
+ align-items: baseline;
4338
+ margin-bottom: 4px;
4639
4339
  }
4640
- /**
4641
- * Inject dependencies after construction.
4642
- * Called from index.ts after all components are initialized.
4643
- */
4644
- setDependencies(deps) {
4645
- this.policy = deps.policy;
4646
- this.baseline = deps.baseline;
4647
- this.auditLog = deps.auditLog;
4340
+
4341
+ .activity-tier {
4342
+ display: inline-flex;
4343
+ align-items: center;
4344
+ justify-content: center;
4345
+ width: 24px;
4346
+ height: 16px;
4347
+ font-size: 10px;
4348
+ font-weight: 700;
4349
+ border-radius: 3px;
4350
+ text-transform: uppercase;
4351
+ flex: 0 0 auto;
4648
4352
  }
4649
- /**
4650
- * Start the HTTP(S) server for the dashboard.
4651
- */
4652
- async start() {
4653
- return new Promise((resolve, reject) => {
4654
- const handler = (req, res) => this.handleRequest(req, res);
4655
- if (this.useTLS && this.config.tls) {
4656
- const tlsOpts = {
4657
- cert: readFileSync(this.config.tls.cert_path),
4658
- key: readFileSync(this.config.tls.key_path)
4659
- };
4660
- this.httpServer = createServer$1(tlsOpts, handler);
4661
- } else {
4662
- this.httpServer = createServer$2(handler);
4663
- }
4664
- const protocol = this.useTLS ? "https" : "http";
4665
- const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
4666
- this.httpServer.listen(this.config.port, this.config.host, () => {
4667
- if (this.authToken) {
4668
- const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
4669
- process.stderr.write(
4670
- `
4671
- Sanctuary Principal Dashboard: ${baseUrl}
4672
- `
4673
- );
4674
- process.stderr.write(
4675
- ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
4676
4353
 
4677
- `
4678
- );
4679
- } else {
4680
- process.stderr.write(
4681
- `
4682
- Sanctuary Principal Dashboard: ${baseUrl}
4354
+ .activity-tier.t1 {
4355
+ background: rgba(248, 81, 73, 0.2);
4356
+ color: var(--red);
4357
+ }
4683
4358
 
4684
- `
4685
- );
4686
- }
4687
- resolve();
4688
- });
4689
- this.httpServer.on("error", reject);
4690
- });
4359
+ .activity-tier.t2 {
4360
+ background: rgba(210, 153, 34, 0.2);
4361
+ color: var(--amber);
4691
4362
  }
4692
- /**
4693
- * Stop the HTTP server and clean up.
4694
- */
4695
- async stop() {
4696
- for (const [, pending] of this.pending) {
4697
- clearTimeout(pending.timer);
4698
- pending.resolve({
4699
- decision: "deny",
4363
+
4364
+ .activity-tier.t3 {
4365
+ background: rgba(63, 185, 80, 0.2);
4366
+ color: var(--green);
4367
+ }
4368
+
4369
+ .activity-tool {
4370
+ color: var(--text-primary);
4371
+ font-weight: 600;
4372
+ }
4373
+
4374
+ .activity-outcome {
4375
+ color: var(--green);
4376
+ }
4377
+
4378
+ .activity-outcome.denied {
4379
+ color: var(--red);
4380
+ }
4381
+
4382
+ .activity-detail {
4383
+ font-size: 12px;
4384
+ color: var(--text-secondary);
4385
+ margin-left: 0;
4386
+ }
4387
+
4388
+ .activity-item.expanded .activity-detail {
4389
+ display: block;
4390
+ margin-top: 8px;
4391
+ padding: 10px;
4392
+ background: rgba(88, 166, 255, 0.08);
4393
+ border-left: 2px solid var(--blue);
4394
+ border-radius: 4px;
4395
+ }
4396
+
4397
+ .activity-empty {
4398
+ display: flex;
4399
+ flex-direction: column;
4400
+ align-items: center;
4401
+ justify-content: center;
4402
+ height: 100%;
4403
+ color: var(--text-secondary);
4404
+ }
4405
+
4406
+ .activity-empty-icon {
4407
+ font-size: 32px;
4408
+ margin-bottom: 12px;
4409
+ }
4410
+
4411
+ .activity-empty-text {
4412
+ font-size: 14px;
4413
+ }
4414
+
4415
+ /* \u2500\u2500 Protection Status Sidebar (40%) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4416
+
4417
+ .protection-sidebar {
4418
+ flex: 2;
4419
+ display: flex;
4420
+ flex-direction: column;
4421
+ background: rgba(22, 27, 34, 0.5);
4422
+ overflow: hidden;
4423
+ }
4424
+
4425
+ .sidebar-header {
4426
+ padding: 16px 20px;
4427
+ border-bottom: 1px solid var(--border);
4428
+ font-size: 12px;
4429
+ font-weight: 600;
4430
+ text-transform: uppercase;
4431
+ letter-spacing: 0.5px;
4432
+ color: var(--text-secondary);
4433
+ display: flex;
4434
+ align-items: center;
4435
+ gap: 8px;
4436
+ }
4437
+
4438
+ .sidebar-content {
4439
+ flex: 1;
4440
+ overflow-y: auto;
4441
+ padding: 16px 16px;
4442
+ display: grid;
4443
+ grid-template-columns: 1fr 1fr;
4444
+ gap: 12px;
4445
+ }
4446
+
4447
+ .protection-card {
4448
+ background: var(--surface);
4449
+ border: 1px solid var(--border);
4450
+ border-radius: var(--radius);
4451
+ padding: 14px;
4452
+ display: flex;
4453
+ flex-direction: column;
4454
+ gap: 8px;
4455
+ }
4456
+
4457
+ .protection-card-icon {
4458
+ font-size: 14px;
4459
+ }
4460
+
4461
+ .protection-card-label {
4462
+ font-size: 11px;
4463
+ font-weight: 600;
4464
+ text-transform: uppercase;
4465
+ letter-spacing: 0.5px;
4466
+ color: var(--text-secondary);
4467
+ }
4468
+
4469
+ .protection-card-status {
4470
+ display: flex;
4471
+ align-items: center;
4472
+ gap: 6px;
4473
+ font-size: 12px;
4474
+ font-weight: 600;
4475
+ }
4476
+
4477
+ .protection-card-status.active {
4478
+ color: var(--green);
4479
+ }
4480
+
4481
+ .protection-card-status.inactive {
4482
+ color: var(--text-secondary);
4483
+ }
4484
+
4485
+ .protection-card-stat {
4486
+ font-size: 11px;
4487
+ color: var(--text-secondary);
4488
+ font-family: var(--mono);
4489
+ margin-top: 4px;
4490
+ }
4491
+
4492
+ /* \u2500\u2500 Pending Approvals Overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4493
+
4494
+ .pending-overlay {
4495
+ position: fixed;
4496
+ top: 56px;
4497
+ right: 0;
4498
+ bottom: 0;
4499
+ width: 0;
4500
+ background: var(--surface);
4501
+ border-left: 1px solid var(--border);
4502
+ z-index: 999;
4503
+ overflow-y: auto;
4504
+ transition: width 0.3s ease-out;
4505
+ display: flex;
4506
+ flex-direction: column;
4507
+ }
4508
+
4509
+ .pending-overlay.active {
4510
+ width: 380px;
4511
+ }
4512
+
4513
+ @media (max-width: 1400px) {
4514
+ .pending-overlay.active {
4515
+ width: 100%;
4516
+ right: auto;
4517
+ left: 0;
4518
+ }
4519
+ }
4520
+
4521
+ .pending-overlay-header {
4522
+ padding: 16px 20px;
4523
+ border-bottom: 1px solid var(--border);
4524
+ display: flex;
4525
+ align-items: center;
4526
+ justify-content: space-between;
4527
+ flex: 0 0 auto;
4528
+ }
4529
+
4530
+ .pending-overlay-title {
4531
+ font-size: 13px;
4532
+ font-weight: 600;
4533
+ text-transform: uppercase;
4534
+ letter-spacing: 0.5px;
4535
+ color: var(--text-primary);
4536
+ }
4537
+
4538
+ .pending-overlay-close {
4539
+ background: none;
4540
+ border: none;
4541
+ color: var(--text-secondary);
4542
+ cursor: pointer;
4543
+ font-size: 18px;
4544
+ padding: 0;
4545
+ display: flex;
4546
+ align-items: center;
4547
+ justify-content: center;
4548
+ }
4549
+
4550
+ .pending-overlay-close:hover {
4551
+ color: var(--text-primary);
4552
+ }
4553
+
4554
+ .pending-list {
4555
+ flex: 1;
4556
+ overflow-y: auto;
4557
+ }
4558
+
4559
+ .pending-item {
4560
+ padding: 16px 20px;
4561
+ border-bottom: 1px solid rgba(48, 54, 61, 0.5);
4562
+ display: flex;
4563
+ flex-direction: column;
4564
+ gap: 10px;
4565
+ }
4566
+
4567
+ .pending-item-header {
4568
+ display: flex;
4569
+ align-items: center;
4570
+ gap: 8px;
4571
+ }
4572
+
4573
+ .pending-item-op {
4574
+ font-family: var(--mono);
4575
+ font-size: 12px;
4576
+ font-weight: 600;
4577
+ color: var(--text-primary);
4578
+ flex: 1;
4579
+ }
4580
+
4581
+ .pending-item-tier {
4582
+ display: inline-flex;
4583
+ align-items: center;
4584
+ justify-content: center;
4585
+ width: 28px;
4586
+ height: 20px;
4587
+ font-size: 9px;
4588
+ font-weight: 700;
4589
+ border-radius: 3px;
4590
+ text-transform: uppercase;
4591
+ color: white;
4592
+ }
4593
+
4594
+ .pending-item-tier.tier1 {
4595
+ background: var(--red);
4596
+ }
4597
+
4598
+ .pending-item-tier.tier2 {
4599
+ background: var(--amber);
4600
+ }
4601
+
4602
+ .pending-item-reason {
4603
+ font-size: 12px;
4604
+ color: var(--text-secondary);
4605
+ }
4606
+
4607
+ .pending-item-timer {
4608
+ display: flex;
4609
+ align-items: center;
4610
+ gap: 6px;
4611
+ font-size: 11px;
4612
+ font-family: var(--mono);
4613
+ color: var(--text-secondary);
4614
+ }
4615
+
4616
+ .pending-item-timer-bar {
4617
+ flex: 1;
4618
+ height: 4px;
4619
+ background: rgba(48, 54, 61, 0.8);
4620
+ border-radius: 2px;
4621
+ overflow: hidden;
4622
+ }
4623
+
4624
+ .pending-item-timer-fill {
4625
+ height: 100%;
4626
+ background: var(--blue);
4627
+ transition: width 0.1s linear;
4628
+ }
4629
+
4630
+ .pending-item-timer.urgent .pending-item-timer-fill {
4631
+ background: var(--red);
4632
+ }
4633
+
4634
+ .pending-item-actions {
4635
+ display: flex;
4636
+ gap: 8px;
4637
+ }
4638
+
4639
+ .btn {
4640
+ flex: 1;
4641
+ padding: 8px 12px;
4642
+ border: none;
4643
+ border-radius: var(--radius);
4644
+ font-size: 12px;
4645
+ font-weight: 600;
4646
+ cursor: pointer;
4647
+ transition: all 0.15s;
4648
+ font-family: var(--sans);
4649
+ }
4650
+
4651
+ .btn-approve {
4652
+ background: var(--green);
4653
+ color: var(--bg);
4654
+ }
4655
+
4656
+ .btn-approve:hover {
4657
+ background: #4ecf5e;
4658
+ }
4659
+
4660
+ .btn-deny {
4661
+ background: var(--red);
4662
+ color: white;
4663
+ }
4664
+
4665
+ .btn-deny:hover {
4666
+ background: #f9605e;
4667
+ }
4668
+
4669
+ /* \u2500\u2500 Threat Panel (collapsible footer) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4670
+
4671
+ .threat-panel {
4672
+ position: fixed;
4673
+ bottom: 0;
4674
+ left: 0;
4675
+ right: 0;
4676
+ background: var(--surface);
4677
+ border-top: 1px solid var(--border);
4678
+ max-height: 240px;
4679
+ z-index: 500;
4680
+ display: flex;
4681
+ flex-direction: column;
4682
+ transition: max-height 0.3s ease-out;
4683
+ }
4684
+
4685
+ .threat-panel.collapsed {
4686
+ max-height: 40px;
4687
+ }
4688
+
4689
+ .threat-header {
4690
+ padding: 12px 20px;
4691
+ cursor: pointer;
4692
+ display: flex;
4693
+ align-items: center;
4694
+ gap: 8px;
4695
+ font-size: 12px;
4696
+ font-weight: 600;
4697
+ text-transform: uppercase;
4698
+ letter-spacing: 0.5px;
4699
+ color: var(--text-secondary);
4700
+ flex: 0 0 auto;
4701
+ }
4702
+
4703
+ .threat-header:hover {
4704
+ background: rgba(88, 166, 255, 0.05);
4705
+ }
4706
+
4707
+ .threat-icon {
4708
+ font-size: 14px;
4709
+ }
4710
+
4711
+ .threat-content {
4712
+ flex: 1;
4713
+ overflow-y: auto;
4714
+ padding: 0 20px 12px;
4715
+ display: flex;
4716
+ flex-direction: column;
4717
+ gap: 10px;
4718
+ }
4719
+
4720
+ .threat-item {
4721
+ padding: 8px 10px;
4722
+ background: rgba(248, 81, 73, 0.1);
4723
+ border-left: 2px solid var(--red);
4724
+ border-radius: 4px;
4725
+ font-size: 11px;
4726
+ color: var(--text-secondary);
4727
+ }
4728
+
4729
+ .threat-item-type {
4730
+ font-weight: 600;
4731
+ color: var(--red);
4732
+ font-family: var(--mono);
4733
+ }
4734
+
4735
+ .threat-empty {
4736
+ text-align: center;
4737
+ padding: 20px 10px;
4738
+ color: var(--text-secondary);
4739
+ font-size: 12px;
4740
+ }
4741
+
4742
+ /* \u2500\u2500 Scrollbars \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4743
+
4744
+ ::-webkit-scrollbar {
4745
+ width: 6px;
4746
+ }
4747
+
4748
+ ::-webkit-scrollbar-track {
4749
+ background: transparent;
4750
+ }
4751
+
4752
+ ::-webkit-scrollbar-thumb {
4753
+ background: var(--border);
4754
+ border-radius: 3px;
4755
+ }
4756
+
4757
+ ::-webkit-scrollbar-thumb:hover {
4758
+ background: rgba(88, 166, 255, 0.3);
4759
+ }
4760
+
4761
+ /* \u2500\u2500 Responsive \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4762
+
4763
+ @media (max-width: 1200px) {
4764
+ .protection-sidebar {
4765
+ display: none;
4766
+ }
4767
+
4768
+ .activity-feed {
4769
+ border-right: none;
4770
+ }
4771
+ }
4772
+
4773
+ @media (max-width: 768px) {
4774
+ .status-bar {
4775
+ padding: 0 12px;
4776
+ gap: 12px;
4777
+ height: 48px;
4778
+ }
4779
+
4780
+ .sanctuary-logo {
4781
+ font-size: 14px;
4782
+ }
4783
+
4784
+ .status-bar-center {
4785
+ display: none;
4786
+ }
4787
+
4788
+ .main-container {
4789
+ margin-top: 48px;
4790
+ }
4791
+
4792
+ .activity-item {
4793
+ padding: 10px 12px;
4794
+ }
4795
+
4796
+ .pending-overlay.active {
4797
+ width: 100%;
4798
+ }
4799
+
4800
+ .threat-panel {
4801
+ max-height: 200px;
4802
+ }
4803
+ }
4804
+ </style>
4805
+ </head>
4806
+ <body>
4807
+
4808
+ <!-- Status Bar (fixed, top) -->
4809
+ <div class="status-bar">
4810
+ <div class="status-bar-left">
4811
+ <div class="sanctuary-logo"><span>\u25C6</span> SANCTUARY</div>
4812
+ <div class="version">v${options.serverVersion}</div>
4813
+ </div>
4814
+ <div class="status-bar-center">
4815
+ <div class="sovereignty-badge">
4816
+ <div class="sovereignty-score" id="sovereigntyScore">85</div>
4817
+ <span>Sovereignty Health</span>
4818
+ </div>
4819
+ </div>
4820
+ <div class="status-bar-right">
4821
+ <div class="protections-indicator">
4822
+ <span class="count" id="activeProtections">6</span>/6 protections
4823
+ </div>
4824
+ <div class="uptime">
4825
+ <span id="uptimeText">\u2014</span>
4826
+ </div>
4827
+ <div class="status-dot" id="statusDot"></div>
4828
+ <div class="pending-badge hidden" id="pendingBadge">0</div>
4829
+ </div>
4830
+ </div>
4831
+
4832
+ <!-- Main Layout -->
4833
+ <div class="main-container">
4834
+ <!-- Activity Feed -->
4835
+ <div class="activity-feed">
4836
+ <div class="feed-header">
4837
+ <div class="feed-header-dot"></div>
4838
+ Live Activity
4839
+ </div>
4840
+ <div class="activity-list" id="activityList">
4841
+ <div class="activity-empty">
4842
+ <div class="activity-empty-icon">\u2192</div>
4843
+ <div class="activity-empty-text">Waiting for activity...</div>
4844
+ </div>
4845
+ </div>
4846
+ </div>
4847
+
4848
+ <!-- Protection Status Sidebar -->
4849
+ <div class="protection-sidebar" id="protectionSidebar">
4850
+ <div class="sidebar-header">
4851
+ <span>\u25C6</span> Protection Status
4852
+ </div>
4853
+ <div class="sidebar-content">
4854
+ <div class="protection-card">
4855
+ <div class="protection-card-icon">\u{1F510}</div>
4856
+ <div class="protection-card-label">Encryption</div>
4857
+ <div class="protection-card-status active" id="encryptionStatus">\u2713 Active</div>
4858
+ <div class="protection-card-stat" id="encryptionStat">Ed25519</div>
4859
+ </div>
4860
+
4861
+ <div class="protection-card">
4862
+ <div class="protection-card-icon">\u2713</div>
4863
+ <div class="protection-card-label">Approval Gate</div>
4864
+ <div class="protection-card-status active" id="approvalStatus">\u2713 Active</div>
4865
+ <div class="protection-card-stat" id="approvalStat">T1: 2 | T2: 3</div>
4866
+ </div>
4867
+
4868
+ <div class="protection-card">
4869
+ <div class="protection-card-icon">\u{1F3AF}</div>
4870
+ <div class="protection-card-label">Context Gating</div>
4871
+ <div class="protection-card-status active" id="contextStatus">\u2713 Active</div>
4872
+ <div class="protection-card-stat" id="contextStat">12 filtered</div>
4873
+ </div>
4874
+
4875
+ <div class="protection-card">
4876
+ <div class="protection-card-icon">\u26A0</div>
4877
+ <div class="protection-card-label">Injection Detection</div>
4878
+ <div class="protection-card-status active" id="injectionStatus">\u2713 Active</div>
4879
+ <div class="protection-card-stat" id="injectionStat">3 flags today</div>
4880
+ </div>
4881
+
4882
+ <div class="protection-card">
4883
+ <div class="protection-card-icon">\u{1F4CA}</div>
4884
+ <div class="protection-card-label">Behavioral Baseline</div>
4885
+ <div class="protection-card-status active" id="baselineStatus">\u2713 Active</div>
4886
+ <div class="protection-card-stat" id="baselineStat">0 anomalies</div>
4887
+ </div>
4888
+
4889
+ <div class="protection-card">
4890
+ <div class="protection-card-icon">\u{1F4CB}</div>
4891
+ <div class="protection-card-label">Audit Trail</div>
4892
+ <div class="protection-card-status active" id="auditStatus">\u2713 Active</div>
4893
+ <div class="protection-card-stat" id="auditStat">284 entries</div>
4894
+ </div>
4895
+ </div>
4896
+ </div>
4897
+ </div>
4898
+
4899
+ <!-- Pending Approvals Overlay -->
4900
+ <div class="pending-overlay" id="pendingOverlay">
4901
+ <div class="pending-overlay-header">
4902
+ <div class="pending-overlay-title">Pending Approvals</div>
4903
+ <button class="pending-overlay-close" onclick="closePendingOverlay()">\xD7</button>
4904
+ </div>
4905
+ <div class="pending-list" id="pendingList"></div>
4906
+ </div>
4907
+
4908
+ <!-- Threat Panel (collapsible footer) -->
4909
+ <div class="threat-panel collapsed" id="threatPanel">
4910
+ <div class="threat-header" onclick="toggleThreatPanel()">
4911
+ <span class="threat-icon">\u26A0</span>
4912
+ Recent Threats
4913
+ <span id="threatCount" style="margin-left: auto; color: var(--red); font-weight: 700;">0</span>
4914
+ </div>
4915
+ <div class="threat-content" id="threatContent">
4916
+ <div class="threat-empty">No threats detected</div>
4917
+ </div>
4918
+ </div>
4919
+
4920
+ <script>
4921
+ (function() {
4922
+ 'use strict';
4923
+
4924
+ // \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
4925
+
4926
+ const TIMEOUT_SECONDS = ${options.timeoutSeconds};
4927
+ const AUTH_TOKEN = ${options.authToken ? JSON.stringify(options.authToken) : "null"};
4928
+ const MAX_ACTIVITY_ITEMS = 100;
4929
+ const MAX_THREAT_ITEMS = 20;
4930
+
4931
+ // \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4932
+
4933
+ let SESSION_ID = null;
4934
+ let evtSource = null;
4935
+ let startTime = Date.now();
4936
+ let activityCount = 0;
4937
+ let threatCount = 0;
4938
+ const pendingRequests = new Map();
4939
+ const activityItems = [];
4940
+ const threatItems = [];
4941
+ let sovereigntyScore = 85;
4942
+
4943
+ // \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
4944
+
4945
+ function authHeaders() {
4946
+ const h = { 'Content-Type': 'application/json' };
4947
+ if (AUTH_TOKEN) h['Authorization'] = 'Bearer ' + AUTH_TOKEN;
4948
+ return h;
4949
+ }
4950
+
4951
+ function sessionQuery(url) {
4952
+ if (!SESSION_ID) return url;
4953
+ const sep = url.includes('?') ? '&' : '?';
4954
+ return url + sep + 'session=' + SESSION_ID;
4955
+ }
4956
+
4957
+ async function exchangeSession() {
4958
+ if (!AUTH_TOKEN) return;
4959
+ try {
4960
+ const resp = await fetch('/auth/session', { method: 'POST', headers: authHeaders() });
4961
+ if (resp.ok) {
4962
+ const data = await resp.json();
4963
+ SESSION_ID = data.session_id;
4964
+ const refreshMs = (data.expires_in_seconds || 300) * 800;
4965
+ setTimeout(() => { exchangeSession(); reconnectSSE(); }, refreshMs);
4966
+ }
4967
+ } catch (e) {
4968
+ // Retry on next connect
4969
+ }
4970
+ }
4971
+
4972
+ // \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
4973
+
4974
+ function esc(s) {
4975
+ const d = document.createElement('div');
4976
+ d.textContent = String(s || '');
4977
+ return d.innerHTML;
4978
+ }
4979
+
4980
+ function closePendingOverlay() {
4981
+ document.getElementById('pendingOverlay').classList.remove('active');
4982
+ }
4983
+
4984
+ function toggleThreatPanel() {
4985
+ document.getElementById('threatPanel').classList.toggle('collapsed');
4986
+ }
4987
+
4988
+ function updateUptime() {
4989
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
4990
+ const hours = Math.floor(elapsed / 3600);
4991
+ const mins = Math.floor((elapsed % 3600) / 60);
4992
+ const secs = elapsed % 60;
4993
+ let uptimeStr = '';
4994
+ if (hours > 0) uptimeStr += hours + 'h ';
4995
+ if (mins > 0) uptimeStr += mins + 'm ';
4996
+ uptimeStr += secs + 's';
4997
+ document.getElementById('uptimeText').textContent = uptimeStr;
4998
+ }
4999
+
5000
+ // \u2500\u2500 Sovereignty Score \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5001
+
5002
+ function updateSovereigntyScore(score) {
5003
+ sovereigntyScore = Math.min(100, Math.max(0, score || 85));
5004
+ const badge = document.getElementById('sovereigntyScore');
5005
+ badge.textContent = sovereigntyScore;
5006
+ badge.className = 'sovereignty-score';
5007
+ if (sovereigntyScore >= 80) {
5008
+ badge.classList.add('high');
5009
+ } else if (sovereigntyScore >= 50) {
5010
+ badge.classList.add('medium');
5011
+ } else {
5012
+ badge.classList.add('low');
5013
+ }
5014
+ }
5015
+
5016
+ // \u2500\u2500 Activity Feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5017
+
5018
+ function addActivityItem(data) {
5019
+ const {
5020
+ timestamp,
5021
+ tier,
5022
+ tool,
5023
+ outcome,
5024
+ detail,
5025
+ hasInjection,
5026
+ isContextGated
5027
+ } = data;
5028
+
5029
+ const item = {
5030
+ id: 'activity-' + activityCount++,
5031
+ timestamp: timestamp || new Date().toISOString(),
5032
+ tier: tier || 1,
5033
+ tool: tool || 'unknown_tool',
5034
+ outcome: outcome || 'executed',
5035
+ detail: detail || '',
5036
+ hasInjection: !!hasInjection,
5037
+ isContextGated: !!isContextGated
5038
+ };
5039
+
5040
+ activityItems.unshift(item);
5041
+ if (activityItems.length > MAX_ACTIVITY_ITEMS) {
5042
+ activityItems.pop();
5043
+ }
5044
+
5045
+ renderActivityFeed();
5046
+ }
5047
+
5048
+ function renderActivityFeed() {
5049
+ const list = document.getElementById('activityList');
5050
+
5051
+ if (activityItems.length === 0) {
5052
+ list.innerHTML = '<div class="activity-empty"><div class="activity-empty-icon">\u2192</div><div class="activity-empty-text">Waiting for activity...</div></div>';
5053
+ return;
5054
+ }
5055
+
5056
+ list.innerHTML = '';
5057
+ for (const item of activityItems) {
5058
+ const tr = document.createElement('div');
5059
+ tr.className = 'activity-item';
5060
+ tr.id = item.id;
5061
+
5062
+ const time = new Date(item.timestamp);
5063
+ const timeStr = time.toLocaleTimeString();
5064
+
5065
+ const tierClass = 't' + item.tier;
5066
+ const outcomeClass = item.outcome === 'denied' ? 'outcome denied' : 'outcome';
5067
+
5068
+ let icon = '\u25CF';
5069
+ if (item.isContextGated) icon = '\u{1F3AF}';
5070
+ else if (item.hasInjection) icon = '\u26A0';
5071
+ else if (item.outcome === 'denied') icon = '\u2717';
5072
+ else icon = '\u2713';
5073
+
5074
+ tr.innerHTML =
5075
+ '<div class="activity-item-icon">' + esc(icon) + '</div>' +
5076
+ '<div class="activity-item-content">' +
5077
+ '<div class="activity-time">' + esc(timeStr) + '</div>' +
5078
+ '<div class="activity-main">' +
5079
+ '<span class="activity-tier ' + tierClass + '">T' + item.tier + '</span>' +
5080
+ '<span class="activity-tool">' + esc(item.tool) + '</span>' +
5081
+ '<span class="activity-outcome ' + (outcomeClass === 'outcome denied' ? 'denied' : '') + '">' + (item.outcome === 'denied' ? '\u2717 denied' : '\u2713 allowed') + '</span>' +
5082
+ '</div>' +
5083
+ '<div class="activity-detail">' + esc(item.detail) + '</div>' +
5084
+ '</div>' +
5085
+ '';
5086
+
5087
+ tr.addEventListener('click', () => {
5088
+ tr.classList.toggle('expanded');
5089
+ });
5090
+
5091
+ list.appendChild(tr);
5092
+ }
5093
+ }
5094
+
5095
+ // \u2500\u2500 Pending Approvals \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5096
+
5097
+ function addPendingRequest(data) {
5098
+ const {
5099
+ request_id,
5100
+ operation,
5101
+ tier,
5102
+ reason,
5103
+ context,
5104
+ timestamp
5105
+ } = data;
5106
+
5107
+ const pending = {
5108
+ id: request_id,
5109
+ operation: operation || 'unknown',
5110
+ tier: tier || 1,
5111
+ reason: reason || '',
5112
+ context: context || {},
5113
+ timestamp: timestamp || new Date().toISOString(),
5114
+ remaining: TIMEOUT_SECONDS
5115
+ };
5116
+
5117
+ pendingRequests.set(request_id, pending);
5118
+ updatePendingUI();
5119
+ }
5120
+
5121
+ function removePendingRequest(id) {
5122
+ pendingRequests.delete(id);
5123
+ updatePendingUI();
5124
+ }
5125
+
5126
+ function updatePendingUI() {
5127
+ const count = pendingRequests.size;
5128
+ const badge = document.getElementById('pendingBadge');
5129
+
5130
+ if (count > 0) {
5131
+ badge.classList.remove('hidden');
5132
+ badge.textContent = count;
5133
+ document.getElementById('pendingOverlay').classList.add('active');
5134
+ } else {
5135
+ badge.classList.add('hidden');
5136
+ document.getElementById('pendingOverlay').classList.remove('active');
5137
+ }
5138
+
5139
+ renderPendingList();
5140
+ }
5141
+
5142
+ function renderPendingList() {
5143
+ const list = document.getElementById('pendingList');
5144
+ list.innerHTML = '';
5145
+
5146
+ for (const [id, req] of pendingRequests) {
5147
+ const item = document.createElement('div');
5148
+ item.className = 'pending-item';
5149
+
5150
+ const tier = req.tier || 1;
5151
+ const tierClass = 'tier' + tier;
5152
+ const pct = Math.max(0, Math.min(100, (req.remaining / TIMEOUT_SECONDS) * 100));
5153
+ const isUrgent = req.remaining <= 30;
5154
+
5155
+ item.innerHTML =
5156
+ '<div class="pending-item-header">' +
5157
+ '<div class="pending-item-op">' + esc(req.operation) + '</div>' +
5158
+ '<div class="pending-item-tier ' + tierClass + '">T' + tier + '</div>' +
5159
+ '</div>' +
5160
+ '<div class="pending-item-reason">' + esc(req.reason) + '</div>' +
5161
+ '<div class="pending-item-timer ' + (isUrgent ? 'urgent' : '') + '">' +
5162
+ '<div class="pending-item-timer-bar">' +
5163
+ '<div class="pending-item-timer-fill" style="width: ' + pct + '%"></div>' +
5164
+ '</div>' +
5165
+ '<span id="timer-' + id + '">' + req.remaining + 's</span>' +
5166
+ '</div>' +
5167
+ '<div class="pending-item-actions">' +
5168
+ '<button class="btn btn-approve" onclick="handleApprove('' + id + '')">Approve</button>' +
5169
+ '<button class="btn btn-deny" onclick="handleDeny('' + id + '')">Deny</button>' +
5170
+ '</div>' +
5171
+ '';
5172
+
5173
+ list.appendChild(item);
5174
+ }
5175
+ }
5176
+
5177
+ window.handleApprove = function(id) {
5178
+ fetch('/api/approve/' + id, { method: 'POST', headers: authHeaders() }).then(() => {
5179
+ removePendingRequest(id);
5180
+ }).catch(() => {});
5181
+ };
5182
+
5183
+ window.handleDeny = function(id) {
5184
+ fetch('/api/deny/' + id, { method: 'POST', headers: authHeaders() }).then(() => {
5185
+ removePendingRequest(id);
5186
+ }).catch(() => {});
5187
+ };
5188
+
5189
+ // \u2500\u2500 Threats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5190
+
5191
+ function addThreat(data) {
5192
+ const {
5193
+ timestamp,
5194
+ severity,
5195
+ type,
5196
+ details
5197
+ } = data;
5198
+
5199
+ const threat = {
5200
+ id: 'threat-' + threatCount++,
5201
+ timestamp: timestamp || new Date().toISOString(),
5202
+ severity: severity || 'medium',
5203
+ type: type || 'unknown',
5204
+ details: details || ''
5205
+ };
5206
+
5207
+ threatItems.unshift(threat);
5208
+ if (threatItems.length > MAX_THREAT_ITEMS) {
5209
+ threatItems.pop();
5210
+ }
5211
+
5212
+ if (threatCount > 0) {
5213
+ document.getElementById('threatPanel').classList.remove('collapsed');
5214
+ }
5215
+
5216
+ renderThreats();
5217
+ }
5218
+
5219
+ function renderThreats() {
5220
+ const content = document.getElementById('threatContent');
5221
+ const badge = document.getElementById('threatCount');
5222
+
5223
+ if (threatItems.length === 0) {
5224
+ content.innerHTML = '<div class="threat-empty">No threats detected</div>';
5225
+ badge.textContent = '0';
5226
+ return;
5227
+ }
5228
+
5229
+ badge.textContent = threatItems.length;
5230
+ content.innerHTML = '';
5231
+
5232
+ for (const threat of threatItems) {
5233
+ const div = document.createElement('div');
5234
+ div.className = 'threat-item';
5235
+ const time = new Date(threat.timestamp).toLocaleTimeString();
5236
+ div.innerHTML =
5237
+ '<div style="margin-bottom: 3px;">' +
5238
+ '<span class="threat-item-type">' + esc(threat.type) + '</span>' +
5239
+ '<span style="font-size: 10px; color: var(--text-secondary); margin-left: 6px;">' + esc(time) + '</span>' +
5240
+ '</div>' +
5241
+ '<div>' + esc(threat.details) + '</div>' +
5242
+ '';
5243
+ content.appendChild(div);
5244
+ }
5245
+ }
5246
+
5247
+ // \u2500\u2500 SSE Connection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5248
+
5249
+ function reconnectSSE() {
5250
+ if (evtSource) evtSource.close();
5251
+ connect();
5252
+ }
5253
+
5254
+ function connect() {
5255
+ evtSource = new EventSource(sessionQuery('/events'));
5256
+
5257
+ evtSource.onopen = () => {
5258
+ document.getElementById('statusDot').classList.remove('disconnected');
5259
+ };
5260
+
5261
+ evtSource.onerror = () => {
5262
+ document.getElementById('statusDot').classList.add('disconnected');
5263
+ };
5264
+
5265
+ evtSource.addEventListener('init', (e) => {
5266
+ const data = JSON.parse(e.data);
5267
+ if (data.baseline) {
5268
+ updateBaseline(data.baseline);
5269
+ }
5270
+ if (data.policy) {
5271
+ updatePolicy(data.policy);
5272
+ }
5273
+ if (data.pending) {
5274
+ data.pending.forEach(addPendingRequest);
5275
+ }
5276
+ });
5277
+
5278
+ evtSource.addEventListener('pending-request', (e) => {
5279
+ const data = JSON.parse(e.data);
5280
+ addPendingRequest(data);
5281
+ });
5282
+
5283
+ evtSource.addEventListener('request-resolved', (e) => {
5284
+ const data = JSON.parse(e.data);
5285
+ removePendingRequest(data.request_id);
5286
+ });
5287
+
5288
+ evtSource.addEventListener('tool-call', (e) => {
5289
+ const data = JSON.parse(e.data);
5290
+ addActivityItem({
5291
+ timestamp: data.timestamp,
5292
+ tier: data.tier || 1,
5293
+ tool: data.tool || 'unknown',
5294
+ outcome: data.outcome || 'executed',
5295
+ detail: data.detail || ''
5296
+ });
5297
+ });
5298
+
5299
+ evtSource.addEventListener('context-gate-decision', (e) => {
5300
+ const data = JSON.parse(e.data);
5301
+ addActivityItem({
5302
+ timestamp: data.timestamp,
5303
+ tier: data.tier || 1,
5304
+ tool: data.tool || 'unknown',
5305
+ outcome: data.outcome || 'gated',
5306
+ detail: data.fields_filtered ? 'Filtered ' + data.fields_filtered + ' fields' : data.reason || '',
5307
+ isContextGated: true
5308
+ });
5309
+ });
5310
+
5311
+ evtSource.addEventListener('injection-alert', (e) => {
5312
+ const data = JSON.parse(e.data);
5313
+ addActivityItem({
5314
+ timestamp: data.timestamp,
5315
+ tier: data.tier || 2,
5316
+ tool: data.tool || 'unknown',
5317
+ outcome: data.allowed ? 'allowed' : 'denied',
5318
+ detail: data.signal || 'Injection detected',
5319
+ hasInjection: true
5320
+ });
5321
+ addThreat({
5322
+ timestamp: data.timestamp,
5323
+ severity: data.severity || 'medium',
5324
+ type: 'Injection Alert',
5325
+ details: data.signal || 'Suspicious pattern detected'
5326
+ });
5327
+ });
5328
+
5329
+ evtSource.addEventListener('protection-status', (e) => {
5330
+ const data = JSON.parse(e.data);
5331
+ updateProtectionStatus(data);
5332
+ });
5333
+
5334
+ evtSource.addEventListener('audit-entry', (e) => {
5335
+ const data = JSON.parse(e.data);
5336
+ // Audit entries don't show in activity by default, but we could add them
5337
+ });
5338
+
5339
+ evtSource.addEventListener('baseline-update', (e) => {
5340
+ const data = JSON.parse(e.data);
5341
+ updateBaseline(data);
5342
+ });
5343
+ }
5344
+
5345
+ function updateBaseline(baseline) {
5346
+ if (!baseline) return;
5347
+ // Update baseline-derived stats if needed
5348
+ }
5349
+
5350
+ function updatePolicy(policy) {
5351
+ if (!policy) return;
5352
+ // Update policy-derived stats
5353
+ if (policy.approval_channel) {
5354
+ // Policy info updated
5355
+ }
5356
+ }
5357
+
5358
+ function updateProtectionStatus(status) {
5359
+ if (status.sovereignty_score !== undefined) {
5360
+ updateSovereigntyScore(status.sovereignty_score);
5361
+ }
5362
+ if (status.active_protections !== undefined) {
5363
+ document.getElementById('activeProtections').textContent = status.active_protections;
5364
+ }
5365
+ // Update individual protection cards
5366
+ if (status.encryption !== undefined) {
5367
+ const el = document.getElementById('encryptionStatus');
5368
+ el.className = 'protection-card-status ' + (status.encryption ? 'active' : 'inactive');
5369
+ el.textContent = status.encryption ? '\u2713 Active' : '\u2717 Inactive';
5370
+ }
5371
+ if (status.approval_gate !== undefined) {
5372
+ const el = document.getElementById('approvalStatus');
5373
+ el.className = 'protection-card-status ' + (status.approval_gate ? 'active' : 'inactive');
5374
+ el.textContent = status.approval_gate ? '\u2713 Active' : '\u2717 Inactive';
5375
+ }
5376
+ if (status.context_gating !== undefined) {
5377
+ const el = document.getElementById('contextStatus');
5378
+ el.className = 'protection-card-status ' + (status.context_gating ? 'active' : 'inactive');
5379
+ el.textContent = status.context_gating ? '\u2713 Active' : '\u2717 Inactive';
5380
+ }
5381
+ if (status.injection_detection !== undefined) {
5382
+ const el = document.getElementById('injectionStatus');
5383
+ el.className = 'protection-card-status ' + (status.injection_detection ? 'active' : 'inactive');
5384
+ el.textContent = status.injection_detection ? '\u2713 Active' : '\u2717 Inactive';
5385
+ }
5386
+ if (status.baseline !== undefined) {
5387
+ const el = document.getElementById('baselineStatus');
5388
+ el.className = 'protection-card-status ' + (status.baseline ? 'active' : 'inactive');
5389
+ el.textContent = status.baseline ? '\u2713 Active' : '\u2717 Inactive';
5390
+ }
5391
+ if (status.audit_trail !== undefined) {
5392
+ const el = document.getElementById('auditStatus');
5393
+ el.className = 'protection-card-status ' + (status.audit_trail ? 'active' : 'inactive');
5394
+ el.textContent = status.audit_trail ? '\u2713 Active' : '\u2717 Inactive';
5395
+ }
5396
+ }
5397
+
5398
+ // \u2500\u2500 Initialization \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5399
+
5400
+ (async function init() {
5401
+ await exchangeSession();
5402
+ // Clean legacy ?token= from URL
5403
+ if (window.location.search.includes('token=')) {
5404
+ window.history.replaceState({}, '', window.location.pathname);
5405
+ }
5406
+ connect();
5407
+
5408
+ // Start uptime ticker
5409
+ setInterval(updateUptime, 1000);
5410
+ updateUptime();
5411
+
5412
+ // Pending request countdown timer
5413
+ setInterval(() => {
5414
+ for (const [id, req] of pendingRequests) {
5415
+ req.remaining = Math.max(0, req.remaining - 1);
5416
+ const el = document.getElementById('timer-' + id);
5417
+ if (el) {
5418
+ el.textContent = req.remaining + 's';
5419
+ }
5420
+ }
5421
+ }, 1000);
5422
+
5423
+ // Load initial status
5424
+ try {
5425
+ const resp = await fetch('/api/status', { headers: authHeaders() });
5426
+ if (resp.ok) {
5427
+ const status = await resp.json();
5428
+ if (status.baseline) updateBaseline(status.baseline);
5429
+ if (status.policy) updatePolicy(status.policy);
5430
+ }
5431
+ } catch (e) {
5432
+ // Ignore
5433
+ }
5434
+ })();
5435
+
5436
+ })();
5437
+ </script>
5438
+
5439
+ </body>
5440
+ </html>`;
5441
+ }
5442
+
5443
+ // src/principal-policy/dashboard.ts
5444
+ var SESSION_TTL_MS = 5 * 60 * 1e3;
5445
+ var MAX_SESSIONS = 1e3;
5446
+ var RATE_LIMIT_WINDOW_MS = 6e4;
5447
+ var RATE_LIMIT_GENERAL = 120;
5448
+ var RATE_LIMIT_DECISIONS = 20;
5449
+ var MAX_RATE_LIMIT_ENTRIES = 1e4;
5450
+ var DashboardApprovalChannel = class {
5451
+ config;
5452
+ pending = /* @__PURE__ */ new Map();
5453
+ sseClients = /* @__PURE__ */ new Set();
5454
+ httpServer = null;
5455
+ policy = null;
5456
+ baseline = null;
5457
+ auditLog = null;
5458
+ dashboardHTML;
5459
+ authToken;
5460
+ useTLS;
5461
+ /** SEC-012: Short-lived session store. Sessions replace URL query tokens. */
5462
+ sessions = /* @__PURE__ */ new Map();
5463
+ sessionCleanupTimer = null;
5464
+ /** Rate limiting: per-IP request tracking */
5465
+ rateLimits = /* @__PURE__ */ new Map();
5466
+ constructor(config) {
5467
+ this.config = config;
5468
+ this.authToken = config.auth_token;
5469
+ this.useTLS = !!(config.tls?.cert_path && config.tls?.key_path);
5470
+ this.dashboardHTML = generateDashboardHTML({
5471
+ timeoutSeconds: config.timeout_seconds,
5472
+ serverVersion: SANCTUARY_VERSION,
5473
+ authToken: this.authToken
5474
+ });
5475
+ this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
5476
+ }
5477
+ /**
5478
+ * Inject dependencies after construction.
5479
+ * Called from index.ts after all components are initialized.
5480
+ */
5481
+ setDependencies(deps) {
5482
+ this.policy = deps.policy;
5483
+ this.baseline = deps.baseline;
5484
+ this.auditLog = deps.auditLog;
5485
+ }
5486
+ /**
5487
+ * Start the HTTP(S) server for the dashboard.
5488
+ */
5489
+ async start() {
5490
+ return new Promise((resolve, reject) => {
5491
+ const handler = (req, res) => this.handleRequest(req, res);
5492
+ if (this.useTLS && this.config.tls) {
5493
+ const tlsOpts = {
5494
+ cert: readFileSync(this.config.tls.cert_path),
5495
+ key: readFileSync(this.config.tls.key_path)
5496
+ };
5497
+ this.httpServer = createServer$1(tlsOpts, handler);
5498
+ } else {
5499
+ this.httpServer = createServer$2(handler);
5500
+ }
5501
+ const protocol = this.useTLS ? "https" : "http";
5502
+ const baseUrl = `${protocol}://${this.config.host}:${this.config.port}`;
5503
+ this.httpServer.listen(this.config.port, this.config.host, () => {
5504
+ if (this.authToken) {
5505
+ const hint = this.authToken.slice(0, 4) + "..." + this.authToken.slice(-4);
5506
+ process.stderr.write(
5507
+ `
5508
+ Sanctuary Principal Dashboard: ${baseUrl}
5509
+ `
5510
+ );
5511
+ process.stderr.write(
5512
+ ` Auth required (token: ${hint}). Use Authorization: Bearer <TOKEN> header.
5513
+
5514
+ `
5515
+ );
5516
+ } else {
5517
+ process.stderr.write(
5518
+ `
5519
+ Sanctuary Principal Dashboard: ${baseUrl}
5520
+
5521
+ `
5522
+ );
5523
+ }
5524
+ resolve();
5525
+ });
5526
+ this.httpServer.on("error", reject);
5527
+ });
5528
+ }
5529
+ /**
5530
+ * Stop the HTTP server and clean up.
5531
+ */
5532
+ async stop() {
5533
+ for (const [, pending] of this.pending) {
5534
+ clearTimeout(pending.timer);
5535
+ pending.resolve({
5536
+ decision: "deny",
4700
5537
  decided_at: (/* @__PURE__ */ new Date()).toISOString(),
4701
5538
  decided_by: "auto"
4702
5539
  });
@@ -5134,6 +5971,25 @@ data: ${JSON.stringify(data)}
5134
5971
  this.broadcastSSE("baseline-update", this.baseline.getProfile());
5135
5972
  }
5136
5973
  }
5974
+ /**
5975
+ * Broadcast a tool call event to connected dashboards.
5976
+ * Called from the gate or router when a tool is invoked.
5977
+ */
5978
+ broadcastToolCall(data) {
5979
+ this.broadcastSSE("tool-call", data);
5980
+ }
5981
+ /**
5982
+ * Broadcast a context gate decision to connected dashboards.
5983
+ */
5984
+ broadcastContextGateDecision(data) {
5985
+ this.broadcastSSE("context-gate-decision", data);
5986
+ }
5987
+ /**
5988
+ * Broadcast current protection status to connected dashboards.
5989
+ */
5990
+ broadcastProtectionStatus(data) {
5991
+ this.broadcastSSE("protection-status", data);
5992
+ }
5137
5993
  /** Get the number of pending requests */
5138
5994
  get pendingCount() {
5139
5995
  return this.pending.size;
@@ -5343,36 +6199,554 @@ var WebhookApprovalChannel = class {
5343
6199
  );
5344
6200
  return;
5345
6201
  }
5346
- const pending = this.pending.get(requestId);
5347
- if (!pending) {
5348
- res.writeHead(404, { "Content-Type": "application/json" });
5349
- res.end(
5350
- JSON.stringify({
5351
- error: "Request not found or already resolved"
5352
- })
5353
- );
5354
- return;
6202
+ const pending = this.pending.get(requestId);
6203
+ if (!pending) {
6204
+ res.writeHead(404, { "Content-Type": "application/json" });
6205
+ res.end(
6206
+ JSON.stringify({
6207
+ error: "Request not found or already resolved"
6208
+ })
6209
+ );
6210
+ return;
6211
+ }
6212
+ clearTimeout(pending.timer);
6213
+ this.pending.delete(requestId);
6214
+ const response = {
6215
+ decision: callbackPayload.decision,
6216
+ decided_at: (/* @__PURE__ */ new Date()).toISOString(),
6217
+ decided_by: "human"
6218
+ };
6219
+ pending.resolve(response);
6220
+ res.writeHead(200, { "Content-Type": "application/json" });
6221
+ res.end(
6222
+ JSON.stringify({
6223
+ success: true,
6224
+ decision: callbackPayload.decision
6225
+ })
6226
+ );
6227
+ });
6228
+ }
6229
+ /** Get the number of pending requests */
6230
+ get pendingCount() {
6231
+ return this.pending.size;
6232
+ }
6233
+ };
6234
+
6235
+ // src/security/injection-detector.ts
6236
+ var ROLE_OVERRIDE_PATTERNS = [
6237
+ /ignore\s+(?:(?:previous|prior|all)\s+)?instructions/i,
6238
+ /you\s+are\s+now/i,
6239
+ /\bsystem\s*:\s+(?!working|process|design|architecture)/i,
6240
+ /forget\s+(?:everything|all|prior)/i,
6241
+ /disregard\s+(?:the\s+)?(?:previous\s+)?instructions/i,
6242
+ /new\s+instructions\s*:/i,
6243
+ /updated?\s+instructions\s*:/i
6244
+ ];
6245
+ var SECURITY_BYPASS_PATTERNS = [
6246
+ /skip\s+(?:the\s+)?(?:filter|gate|check|verify|approve)/i,
6247
+ /bypass\s+(?:the\s+)?(?:filter|gate|security|check)/i,
6248
+ /disable\s+(?:the\s+)?(?:filter|gate|approval|security|audit|log|encrypt|verify)/i,
6249
+ /do\s+not\s+(?:audit|log|encrypt|verify|approve|check|sign)/i
6250
+ ];
6251
+ var TOOL_INVOCATION_PATTERNS = [
6252
+ /sanctuary\//i,
6253
+ /concordia\//i,
6254
+ /bridge_/i,
6255
+ /handshake_/i
6256
+ ];
6257
+ var URL_PATTERN = /https?:\/\/[^\s"'<>]+/i;
6258
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
6259
+ var ZERO_WIDTH_CHARS = [
6260
+ "\u200B",
6261
+ // Zero-width space
6262
+ "\u200C",
6263
+ // Zero-width non-joiner
6264
+ "\u200D",
6265
+ // Zero-width joiner
6266
+ "\uFEFF"
6267
+ // Zero-width no-break space
6268
+ ];
6269
+ var InjectionDetector = class {
6270
+ config;
6271
+ stats = {
6272
+ total_scans: 0,
6273
+ total_flags: 0,
6274
+ total_blocks: 0,
6275
+ signals_by_type: {}
6276
+ };
6277
+ constructor(config = {}) {
6278
+ this.config = {
6279
+ enabled: config.enabled ?? true,
6280
+ sensitivity: config.sensitivity ?? "medium",
6281
+ on_detection: config.on_detection ?? "escalate",
6282
+ custom_patterns: config.custom_patterns ?? []
6283
+ };
6284
+ }
6285
+ /**
6286
+ * Scan tool arguments for injection signals.
6287
+ * @param toolName Full tool name (e.g., "sanctuary/state_read")
6288
+ * @param args Tool arguments
6289
+ * @returns DetectionResult with all detected signals
6290
+ */
6291
+ scan(toolName, args) {
6292
+ this.stats.total_scans++;
6293
+ if (!this.config.enabled) {
6294
+ return {
6295
+ flagged: false,
6296
+ confidence: 0,
6297
+ signals: [],
6298
+ recommendation: "allow"
6299
+ };
6300
+ }
6301
+ const signals = [];
6302
+ const visited = /* @__PURE__ */ new Set();
6303
+ this.scanValue(args, "", toolName, signals, visited);
6304
+ const flagged = signals.length > 0;
6305
+ if (flagged) {
6306
+ this.stats.total_flags++;
6307
+ }
6308
+ for (const sig of signals) {
6309
+ this.stats.signals_by_type[sig.type] = (this.stats.signals_by_type[sig.type] ?? 0) + 1;
6310
+ }
6311
+ const recommendation = this.computeRecommendation(
6312
+ signals,
6313
+ this.config.sensitivity
6314
+ );
6315
+ if (recommendation === "block") {
6316
+ this.stats.total_blocks++;
6317
+ }
6318
+ return {
6319
+ flagged,
6320
+ confidence: this.computeConfidence(signals),
6321
+ signals,
6322
+ recommendation
6323
+ };
6324
+ }
6325
+ /**
6326
+ * Recursively scan a value and all nested values.
6327
+ */
6328
+ scanValue(value, path, toolName, signals, visited) {
6329
+ if (typeof value === "object" && value !== null) {
6330
+ if (visited.has(value)) return;
6331
+ visited.add(value);
6332
+ }
6333
+ if (typeof value === "string") {
6334
+ this.scanString(value, path, toolName, signals);
6335
+ } else if (Array.isArray(value)) {
6336
+ for (let i = 0; i < value.length; i++) {
6337
+ this.scanValue(value[i], `${path}[${i}]`, toolName, signals, visited);
6338
+ }
6339
+ } else if (typeof value === "object" && value !== null) {
6340
+ for (const [key, val] of Object.entries(value)) {
6341
+ this.scanValue(val, path ? `${path}.${key}` : key, toolName, signals, visited);
6342
+ }
6343
+ }
6344
+ }
6345
+ /**
6346
+ * Scan a single string for injection signals.
6347
+ */
6348
+ scanString(value, path, _toolName, signals) {
6349
+ if (this.isSafeField(path)) {
6350
+ return;
6351
+ }
6352
+ const location = path || "root";
6353
+ const normalized = this.normalizeConfusables(value.normalize("NFKC"));
6354
+ if (normalized !== value) {
6355
+ signals.push({
6356
+ type: "encoding_evasion",
6357
+ pattern: "unicode_normalization_delta",
6358
+ location,
6359
+ severity: "medium"
6360
+ });
6361
+ }
6362
+ for (const pattern of ROLE_OVERRIDE_PATTERNS) {
6363
+ if (pattern.test(normalized)) {
6364
+ signals.push({
6365
+ type: "role_override",
6366
+ pattern: pattern.source,
6367
+ location,
6368
+ severity: "high"
6369
+ });
6370
+ break;
6371
+ }
6372
+ }
6373
+ for (const pattern of SECURITY_BYPASS_PATTERNS) {
6374
+ if (pattern.test(normalized)) {
6375
+ signals.push({
6376
+ type: "security_bypass",
6377
+ pattern: pattern.source,
6378
+ location,
6379
+ severity: "high"
6380
+ });
6381
+ break;
6382
+ }
6383
+ }
6384
+ if (!this.isToolNameField(path)) {
6385
+ for (const pattern of TOOL_INVOCATION_PATTERNS) {
6386
+ if (pattern.test(normalized)) {
6387
+ signals.push({
6388
+ type: "tool_invocation_in_string",
6389
+ pattern: pattern.source,
6390
+ location,
6391
+ severity: "medium"
6392
+ });
6393
+ break;
6394
+ }
6395
+ }
6396
+ }
6397
+ this.detectEncodingEvasion(value, location, signals);
6398
+ this.detectDataExfiltration(value, location, signals);
6399
+ this.detectPromptStuffing(value, location, signals);
6400
+ }
6401
+ /**
6402
+ * Detect base64 strings and zero-width character evasion.
6403
+ */
6404
+ detectEncodingEvasion(value, path, signals) {
6405
+ if (value.length > 50 && /^[A-Za-z0-9+/]+={0,2}$/.test(value.trim())) {
6406
+ signals.push({
6407
+ type: "encoding_evasion",
6408
+ pattern: "base64_string",
6409
+ location: path || "root",
6410
+ severity: "medium"
6411
+ });
6412
+ }
6413
+ let zeroWidthCount = 0;
6414
+ for (const char of ZERO_WIDTH_CHARS) {
6415
+ zeroWidthCount += (value.match(new RegExp(char, "g")) || []).length;
6416
+ }
6417
+ if (zeroWidthCount > 0) {
6418
+ signals.push({
6419
+ type: "encoding_evasion",
6420
+ pattern: "zero_width_characters",
6421
+ location: path || "root",
6422
+ severity: "medium"
6423
+ });
6424
+ }
6425
+ const hasLatin = /[a-zA-Z]/.test(value);
6426
+ const hasCJK = /[\u4E00-\u9FFF\u3040-\u309F\uAC00-\uD7AF]/.test(value);
6427
+ const hasArabic = /[\u0600-\u06FF]/.test(value);
6428
+ const hasCyrillic = /[\u0400-\u04FF]/.test(value);
6429
+ const unicodeCategories = [hasLatin, hasCJK, hasArabic, hasCyrillic].filter(
6430
+ (x) => x
6431
+ ).length;
6432
+ if (unicodeCategories >= 3) {
6433
+ signals.push({
6434
+ type: "encoding_evasion",
6435
+ pattern: "unicode_category_mixing",
6436
+ location: path || "root",
6437
+ severity: "medium"
6438
+ });
6439
+ }
6440
+ }
6441
+ /**
6442
+ * Detect URLs and emails in fields that shouldn't have them.
6443
+ */
6444
+ detectDataExfiltration(value, path, signals) {
6445
+ if (this.isUrlSafeField(path)) {
6446
+ return;
6447
+ }
6448
+ if (URL_PATTERN.test(value)) {
6449
+ signals.push({
6450
+ type: "data_exfiltration",
6451
+ pattern: "url_in_string",
6452
+ location: path || "root",
6453
+ severity: "medium"
6454
+ });
6455
+ }
6456
+ if (EMAIL_PATTERN.test(value) && !this.isEmailSafeField(path)) {
6457
+ signals.push({
6458
+ type: "data_exfiltration",
6459
+ pattern: "email_in_string",
6460
+ location: path || "root",
6461
+ severity: "medium"
6462
+ });
6463
+ }
6464
+ if (value.length > 30 && value.length < 1e4 && !this.isStructuredField(path)) {
6465
+ const hasJsonContent = /\{[^}]*"[^"]*"[^}]*\}/.test(value);
6466
+ const hasXmlContent = /<[^>]+>[\s\S]*?<\/[^>]+>/.test(value);
6467
+ if (hasJsonContent || hasXmlContent) {
6468
+ signals.push({
6469
+ type: "data_exfiltration",
6470
+ pattern: "structured_data_in_string",
6471
+ location: path || "root",
6472
+ severity: "medium"
6473
+ });
6474
+ }
6475
+ }
6476
+ }
6477
+ /**
6478
+ * Detect prompt stuffing: very large strings or high repetition.
6479
+ */
6480
+ detectPromptStuffing(value, path, signals) {
6481
+ if (value.length > 10240) {
6482
+ signals.push({
6483
+ type: "prompt_stuffing",
6484
+ pattern: "large_string",
6485
+ location: path || "root",
6486
+ severity: "low"
6487
+ });
6488
+ }
6489
+ if (value.length >= 100) {
6490
+ const windowSizes = [10, 20, 50];
6491
+ for (const windowSize of windowSizes) {
6492
+ if (value.length < windowSize * 5) continue;
6493
+ const pattern = value.substring(0, windowSize);
6494
+ let count = 0;
6495
+ let idx = 0;
6496
+ while (idx <= value.length - windowSize) {
6497
+ if (value.substring(idx, idx + windowSize) === pattern) {
6498
+ count++;
6499
+ idx += windowSize;
6500
+ } else {
6501
+ idx++;
6502
+ }
6503
+ if (count >= 10) break;
6504
+ }
6505
+ if (count >= 10) {
6506
+ signals.push({
6507
+ type: "prompt_stuffing",
6508
+ pattern: "high_repetition",
6509
+ location: path || "root",
6510
+ severity: "low"
6511
+ });
6512
+ break;
6513
+ }
6514
+ }
6515
+ }
6516
+ }
6517
+ /**
6518
+ * Determine if this field is inherently safe from role override.
6519
+ */
6520
+ isSafeField(path) {
6521
+ const safePaths = [
6522
+ /\.version$/i,
6523
+ /\.timestamp$/i,
6524
+ /\.id$/i,
6525
+ /\.uuid$/i,
6526
+ /\.hash$/i,
6527
+ /\.signature$/i,
6528
+ /\.public_key$/i,
6529
+ /\.private_key$/i,
6530
+ /\.did$/i,
6531
+ /\.nonce$/i,
6532
+ /\.salt$/i,
6533
+ /\.iv$/i,
6534
+ /^ciphertext$/i,
6535
+ /^encrypted$/i
6536
+ ];
6537
+ return safePaths.some((p) => p.test(path));
6538
+ }
6539
+ /**
6540
+ * Determine if this is a tool name field (where tool refs are expected).
6541
+ */
6542
+ isToolNameField(path) {
6543
+ const toolFields = [
6544
+ /tool_name/i,
6545
+ /\.tool$/i,
6546
+ /^tool$/i,
6547
+ /operation/i
6548
+ ];
6549
+ return toolFields.some((p) => p.test(path));
6550
+ }
6551
+ /**
6552
+ * Determine if this field is safe for URLs.
6553
+ */
6554
+ isUrlSafeField(path) {
6555
+ const urlFields = [
6556
+ /url/i,
6557
+ /endpoint/i,
6558
+ /webhook/i,
6559
+ /callback/i
6560
+ ];
6561
+ return urlFields.some((p) => p.test(path));
6562
+ }
6563
+ /**
6564
+ * Determine if this field is safe for emails.
6565
+ */
6566
+ isEmailSafeField(path) {
6567
+ const emailFields = [
6568
+ /email/i,
6569
+ /contact/i,
6570
+ /recipient/i,
6571
+ /sender/i,
6572
+ /from/i,
6573
+ /to/i
6574
+ ];
6575
+ return emailFields.some((p) => p.test(path));
6576
+ }
6577
+ /**
6578
+ * Determine if this field is safe for structured data (JSON/XML).
6579
+ */
6580
+ isStructuredField(path) {
6581
+ const structuredFields = [
6582
+ /data/i,
6583
+ /payload/i,
6584
+ /body/i,
6585
+ /json/i,
6586
+ /xml/i
6587
+ ];
6588
+ return structuredFields.some((p) => p.test(path));
6589
+ }
6590
+ /**
6591
+ * SEC-032: Map common cross-script confusable characters to their Latin equivalents.
6592
+ * NFKC normalization handles fullwidth and compatibility forms, but does NOT map
6593
+ * Cyrillic/Greek lookalikes to Latin (they're distinct codepoints by design).
6594
+ * This covers the most common confusables used in injection evasion.
6595
+ */
6596
+ normalizeConfusables(value) {
6597
+ const confusables = {
6598
+ // Cyrillic → Latin
6599
+ "\u0410": "A",
6600
+ "\u0430": "a",
6601
+ // А а
6602
+ "\u0412": "B",
6603
+ "\u0432": "b",
6604
+ // В (not exact) в (not exact)
6605
+ "\u0421": "C",
6606
+ "\u0441": "c",
6607
+ // С с
6608
+ "\u0415": "E",
6609
+ "\u0435": "e",
6610
+ // Е е
6611
+ "\u041D": "H",
6612
+ "\u043D": "h",
6613
+ // Н (not exact) н (not exact)
6614
+ "\u041A": "K",
6615
+ "\u043A": "k",
6616
+ // К к (not exact)
6617
+ "\u041C": "M",
6618
+ "\u043C": "m",
6619
+ // М (not exact) м (not exact)
6620
+ "\u041E": "O",
6621
+ "\u043E": "o",
6622
+ // О о
6623
+ "\u0420": "P",
6624
+ "\u0440": "p",
6625
+ // Р р
6626
+ "\u0422": "T",
6627
+ "\u0442": "t",
6628
+ // Т (not exact) т (not exact)
6629
+ "\u0425": "X",
6630
+ "\u0445": "x",
6631
+ // Х х
6632
+ "\u0423": "Y",
6633
+ "\u0443": "y",
6634
+ // У (not exact) у
6635
+ // Greek → Latin
6636
+ "\u0391": "A",
6637
+ "\u03B1": "a",
6638
+ // Α α (not exact)
6639
+ "\u0392": "B",
6640
+ "\u03B2": "b",
6641
+ // Β β (not exact)
6642
+ "\u0395": "E",
6643
+ "\u03B5": "e",
6644
+ // Ε ε (not exact)
6645
+ "\u0397": "H",
6646
+ // Η
6647
+ "\u0399": "I",
6648
+ "\u03B9": "i",
6649
+ // Ι ι
6650
+ "\u039A": "K",
6651
+ "\u03BA": "k",
6652
+ // Κ κ
6653
+ "\u039C": "M",
6654
+ // Μ
6655
+ "\u039D": "N",
6656
+ // Ν
6657
+ "\u039F": "O",
6658
+ "\u03BF": "o",
6659
+ // Ο ο
6660
+ "\u03A1": "P",
6661
+ "\u03C1": "p",
6662
+ // Ρ ρ (not exact)
6663
+ "\u03A4": "T",
6664
+ "\u03C4": "t",
6665
+ // Τ τ (not exact)
6666
+ "\u03A5": "Y",
6667
+ "\u03C5": "y",
6668
+ // Υ υ (not exact)
6669
+ "\u03A7": "X",
6670
+ "\u03C7": "x"
6671
+ // Χ χ (not exact)
6672
+ };
6673
+ let result = value;
6674
+ if (/[^\x00-\x7F]/.test(value)) {
6675
+ const chars = [];
6676
+ for (const ch of result) {
6677
+ chars.push(confusables[ch] ?? ch);
5355
6678
  }
5356
- clearTimeout(pending.timer);
5357
- this.pending.delete(requestId);
5358
- const response = {
5359
- decision: callbackPayload.decision,
5360
- decided_at: (/* @__PURE__ */ new Date()).toISOString(),
5361
- decided_by: "human"
5362
- };
5363
- pending.resolve(response);
5364
- res.writeHead(200, { "Content-Type": "application/json" });
5365
- res.end(
5366
- JSON.stringify({
5367
- success: true,
5368
- decision: callbackPayload.decision
5369
- })
5370
- );
5371
- });
6679
+ result = chars.join("");
6680
+ }
6681
+ return result;
5372
6682
  }
5373
- /** Get the number of pending requests */
5374
- get pendingCount() {
5375
- return this.pending.size;
6683
+ /**
6684
+ * Compute confidence score based on signals.
6685
+ * More high-severity signals = higher confidence.
6686
+ */
6687
+ computeConfidence(signals) {
6688
+ if (signals.length === 0) return 0;
6689
+ let score = 0;
6690
+ let highCount = 0;
6691
+ for (const sig of signals) {
6692
+ switch (sig.severity) {
6693
+ case "high":
6694
+ highCount++;
6695
+ score += 0.35;
6696
+ break;
6697
+ case "medium":
6698
+ score += 0.15;
6699
+ break;
6700
+ case "low":
6701
+ score += 0.05;
6702
+ break;
6703
+ }
6704
+ }
6705
+ if (highCount > 1) {
6706
+ score += (highCount - 1) * 0.15;
6707
+ }
6708
+ return Math.min(score, 1);
6709
+ }
6710
+ /**
6711
+ * Compute recommendation based on signals and sensitivity.
6712
+ */
6713
+ computeRecommendation(signals, sensitivity) {
6714
+ if (signals.length === 0) return "allow";
6715
+ const highSeverity = signals.filter((s) => s.severity === "high");
6716
+ const mediumSeverity = signals.filter((s) => s.severity === "medium");
6717
+ switch (sensitivity) {
6718
+ case "low":
6719
+ return highSeverity.length > 0 ? "escalate" : "allow";
6720
+ case "medium":
6721
+ if (highSeverity.length > 0) return "block";
6722
+ return mediumSeverity.length > 0 ? "escalate" : "allow";
6723
+ case "high":
6724
+ if (highSeverity.length > 0 || mediumSeverity.length > 1) return "block";
6725
+ if (mediumSeverity.length > 0) return "block";
6726
+ return signals.length > 0 ? "escalate" : "allow";
6727
+ }
6728
+ }
6729
+ /**
6730
+ * Get statistics about scans performed.
6731
+ */
6732
+ getStats() {
6733
+ return {
6734
+ total_scans: this.stats.total_scans,
6735
+ total_flags: this.stats.total_flags,
6736
+ total_blocks: this.stats.total_blocks,
6737
+ signals_by_type: { ...this.stats.signals_by_type }
6738
+ };
6739
+ }
6740
+ /**
6741
+ * Reset statistics.
6742
+ */
6743
+ resetStats() {
6744
+ this.stats = {
6745
+ total_scans: 0,
6746
+ total_flags: 0,
6747
+ total_blocks: 0,
6748
+ signals_by_type: {}
6749
+ };
5376
6750
  }
5377
6751
  };
5378
6752
 
@@ -5382,11 +6756,15 @@ var ApprovalGate = class {
5382
6756
  baseline;
5383
6757
  channel;
5384
6758
  auditLog;
5385
- constructor(policy, baseline, channel, auditLog) {
6759
+ injectionDetector;
6760
+ onInjectionAlert;
6761
+ constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
5386
6762
  this.policy = policy;
5387
6763
  this.baseline = baseline;
5388
6764
  this.channel = channel;
5389
6765
  this.auditLog = auditLog;
6766
+ this.injectionDetector = injectionDetector ?? new InjectionDetector();
6767
+ this.onInjectionAlert = onInjectionAlert;
5390
6768
  }
5391
6769
  /**
5392
6770
  * Evaluate a tool call against the Principal Policy.
@@ -5398,6 +6776,48 @@ var ApprovalGate = class {
5398
6776
  async evaluate(toolName, args) {
5399
6777
  const operation = extractOperationName(toolName);
5400
6778
  this.baseline.recordToolCall(operation);
6779
+ const injectionResult = this.injectionDetector.scan(toolName, args);
6780
+ if (injectionResult.flagged) {
6781
+ this.auditLog.append("l2", `injection_detected:${operation}`, "system", {
6782
+ confidence: injectionResult.confidence,
6783
+ signals: injectionResult.signals.map((s) => ({
6784
+ type: s.type,
6785
+ location: s.location,
6786
+ severity: s.severity
6787
+ })),
6788
+ recommendation: injectionResult.recommendation
6789
+ });
6790
+ if (this.onInjectionAlert) {
6791
+ this.onInjectionAlert({
6792
+ toolName,
6793
+ result: injectionResult,
6794
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6795
+ });
6796
+ }
6797
+ if (injectionResult.recommendation === "block") {
6798
+ return {
6799
+ allowed: false,
6800
+ tier: 1,
6801
+ reason: `Blocked: prompt injection detected in "${operation}" (confidence: ${(injectionResult.confidence * 100).toFixed(0)}%)`,
6802
+ approval_required: false
6803
+ };
6804
+ }
6805
+ if (injectionResult.recommendation === "escalate") {
6806
+ return this.requestApproval(
6807
+ operation,
6808
+ 1,
6809
+ `Potential prompt injection detected in "${operation}" (confidence: ${(injectionResult.confidence * 100).toFixed(0)}%, ${injectionResult.signals.length} signal(s))`,
6810
+ {
6811
+ operation,
6812
+ injection_detection: {
6813
+ confidence: injectionResult.confidence,
6814
+ signal_count: injectionResult.signals.length,
6815
+ signal_types: [...new Set(injectionResult.signals.map((s) => s.type))]
6816
+ }
6817
+ }
6818
+ );
6819
+ }
6820
+ }
5401
6821
  if (this.policy.tier1_always_approve.includes(operation)) {
5402
6822
  return this.requestApproval(operation, 1, `"${operation}" is a Tier 1 operation (always requires approval)`, {
5403
6823
  operation,
@@ -5576,6 +6996,10 @@ var ApprovalGate = class {
5576
6996
  getBaseline() {
5577
6997
  return this.baseline;
5578
6998
  }
6999
+ /** Get the injection detector for stats/configuration access */
7000
+ getInjectionDetector() {
7001
+ return this.injectionDetector;
7002
+ }
5579
7003
  };
5580
7004
 
5581
7005
  // src/principal-policy/tools.ts
@@ -8772,9 +10196,345 @@ function matchesFieldPattern(normalizedField, pattern) {
8772
10196
  return false;
8773
10197
  }
8774
10198
 
10199
+ // src/l2-operational/context-gate-enforcer.ts
10200
+ init_encoding();
10201
+ init_hashing();
10202
+ var BUILTIN_SENSITIVE_PATTERNS = [
10203
+ "*_key",
10204
+ "*_token",
10205
+ "*_secret",
10206
+ "api_key",
10207
+ "access_token",
10208
+ "refresh_token",
10209
+ "password",
10210
+ "passwd",
10211
+ "credential*",
10212
+ "auth_*",
10213
+ "ssn",
10214
+ "social_security*",
10215
+ "tax_id*",
10216
+ "credit_card*",
10217
+ "card_number*",
10218
+ "cvv",
10219
+ "cvc",
10220
+ "private_key",
10221
+ "secret_key",
10222
+ "master_key"
10223
+ ];
10224
+ var ContextGateEnforcer = class {
10225
+ policyStore;
10226
+ auditLog;
10227
+ config;
10228
+ stats = {
10229
+ calls_inspected: 0,
10230
+ calls_bypassed: 0,
10231
+ fields_redacted: 0,
10232
+ fields_hashed: 0,
10233
+ fields_blocked: 0,
10234
+ calls_blocked: 0
10235
+ };
10236
+ constructor(policyStore, auditLog, config) {
10237
+ this.policyStore = policyStore;
10238
+ this.auditLog = auditLog;
10239
+ this.config = config;
10240
+ }
10241
+ /**
10242
+ * Wrap a tool handler to apply automatic context gating.
10243
+ *
10244
+ * The wrapped handler:
10245
+ * 1. Checks if tool should be filtered (based on bypass_prefixes)
10246
+ * 2. If not filtering, calls original handler directly
10247
+ * 3. If filtering:
10248
+ * a. Gets the active policy or falls back to built-in patterns
10249
+ * b. Calls filterContext() with tool arguments
10250
+ * c. If any field triggered "deny" and on_deny is "block", returns error
10251
+ * d. If on_deny is "redact", replaces denied fields with "[REDACTED]"
10252
+ * e. Calls original handler with filtered arguments
10253
+ * f. Logs the filtering decision
10254
+ * 4. In log_only mode: runs filter, logs what would happen, passes original args
10255
+ */
10256
+ wrapHandler(toolName, originalHandler) {
10257
+ return async (args) => {
10258
+ if (!this.config.enabled) {
10259
+ return originalHandler(args);
10260
+ }
10261
+ if (!this.shouldFilter(toolName)) {
10262
+ this.stats.calls_bypassed++;
10263
+ return originalHandler(args);
10264
+ }
10265
+ this.stats.calls_inspected++;
10266
+ const policy = this.config.default_policy_id ? await this.policyStore.get(this.config.default_policy_id) : null;
10267
+ if (policy) {
10268
+ return this.filterWithPolicy(
10269
+ toolName,
10270
+ args,
10271
+ originalHandler,
10272
+ policy
10273
+ );
10274
+ } else {
10275
+ return this.filterWithBuiltinPatterns(
10276
+ toolName,
10277
+ args,
10278
+ originalHandler
10279
+ );
10280
+ }
10281
+ };
10282
+ }
10283
+ /**
10284
+ * Filter tool arguments using an explicit policy.
10285
+ */
10286
+ async filterWithPolicy(toolName, args, originalHandler, policy) {
10287
+ const provider = this.extractProviderCategory(toolName);
10288
+ const result = filterContext(policy, provider, args);
10289
+ const deniedFields = result.decisions.filter((d) => d.action === "deny");
10290
+ if (deniedFields.length > 0) {
10291
+ if (this.config.on_deny === "block") {
10292
+ this.stats.calls_blocked++;
10293
+ this.auditLog.append(
10294
+ "l2",
10295
+ "context_gate_enforcer_block",
10296
+ "system",
10297
+ {
10298
+ tool_name: toolName,
10299
+ policy_id: policy.policy_id,
10300
+ provider,
10301
+ denied_fields: deniedFields.map((d) => d.field),
10302
+ original_context_hash: result.original_context_hash
10303
+ }
10304
+ );
10305
+ return toolResult({
10306
+ error: "context_gating_blocked",
10307
+ message: "Tool call contains fields that trigger deny action",
10308
+ tool: toolName,
10309
+ denied_fields: deniedFields.map((d) => d.field),
10310
+ recommendation: "Remove the denied fields from context or update the context-gating policy."
10311
+ });
10312
+ }
10313
+ }
10314
+ const filteredArgs = this.buildFilteredArgs(args, result.decisions);
10315
+ if (this.config.log_only) {
10316
+ this.auditLog.append(
10317
+ "l2",
10318
+ "context_gate_enforcer_log_only",
10319
+ "system",
10320
+ {
10321
+ tool_name: toolName,
10322
+ policy_id: policy.policy_id,
10323
+ provider,
10324
+ fields_total: Object.keys(args).length,
10325
+ fields_redacted: result.fields_redacted,
10326
+ fields_hashed: result.fields_hashed,
10327
+ fields_blocked: deniedFields.length,
10328
+ original_context_hash: result.original_context_hash
10329
+ }
10330
+ );
10331
+ this.stats.fields_redacted += result.fields_redacted;
10332
+ this.stats.fields_hashed += result.fields_hashed;
10333
+ this.stats.fields_blocked += deniedFields.length;
10334
+ return originalHandler(args);
10335
+ }
10336
+ this.auditLog.append(
10337
+ "l2",
10338
+ "context_gate_enforcer_filter",
10339
+ "system",
10340
+ {
10341
+ tool_name: toolName,
10342
+ policy_id: policy.policy_id,
10343
+ provider,
10344
+ fields_total: Object.keys(args).length,
10345
+ fields_redacted: result.fields_redacted,
10346
+ fields_hashed: result.fields_hashed,
10347
+ fields_blocked: deniedFields.length,
10348
+ original_context_hash: result.original_context_hash
10349
+ }
10350
+ );
10351
+ this.stats.fields_redacted += result.fields_redacted;
10352
+ this.stats.fields_hashed += result.fields_hashed;
10353
+ this.stats.fields_blocked += deniedFields.length;
10354
+ return originalHandler(filteredArgs);
10355
+ }
10356
+ /**
10357
+ * Filter tool arguments using built-in sensitive patterns.
10358
+ * This provides baseline protection when no explicit policy is configured.
10359
+ */
10360
+ async filterWithBuiltinPatterns(toolName, args, originalHandler) {
10361
+ const fieldsToRedact = [];
10362
+ const originalHash = hashToString(
10363
+ stringToBytes(JSON.stringify(args))
10364
+ );
10365
+ for (const field of Object.keys(args)) {
10366
+ if (matchesPattern(field, BUILTIN_SENSITIVE_PATTERNS)) {
10367
+ fieldsToRedact.push(field);
10368
+ }
10369
+ }
10370
+ if (fieldsToRedact.length === 0) {
10371
+ this.auditLog.append(
10372
+ "l2",
10373
+ "context_gate_enforcer_builtin_pass",
10374
+ "system",
10375
+ {
10376
+ tool_name: toolName,
10377
+ reason: "No sensitive field patterns detected"
10378
+ }
10379
+ );
10380
+ return originalHandler(args);
10381
+ }
10382
+ const filteredArgs = {};
10383
+ for (const [key, value] of Object.entries(args)) {
10384
+ if (fieldsToRedact.includes(key)) {
10385
+ filteredArgs[key] = "[REDACTED]";
10386
+ } else {
10387
+ filteredArgs[key] = value;
10388
+ }
10389
+ }
10390
+ const filteredHash = hashToString(
10391
+ stringToBytes(JSON.stringify(filteredArgs))
10392
+ );
10393
+ if (this.config.log_only) {
10394
+ this.auditLog.append(
10395
+ "l2",
10396
+ "context_gate_enforcer_builtin_log_only",
10397
+ "system",
10398
+ {
10399
+ tool_name: toolName,
10400
+ fields_redacted: fieldsToRedact.length,
10401
+ redacted_fields: fieldsToRedact,
10402
+ original_context_hash: originalHash
10403
+ }
10404
+ );
10405
+ this.stats.fields_redacted += fieldsToRedact.length;
10406
+ return originalHandler(args);
10407
+ }
10408
+ this.auditLog.append(
10409
+ "l2",
10410
+ "context_gate_enforcer_builtin_filter",
10411
+ "system",
10412
+ {
10413
+ tool_name: toolName,
10414
+ fields_redacted: fieldsToRedact.length,
10415
+ redacted_fields: fieldsToRedact,
10416
+ original_context_hash: originalHash,
10417
+ filtered_context_hash: filteredHash
10418
+ }
10419
+ );
10420
+ this.stats.fields_redacted += fieldsToRedact.length;
10421
+ return originalHandler(filteredArgs);
10422
+ }
10423
+ /**
10424
+ * Check if a tool should be filtered based on bypass prefixes.
10425
+ *
10426
+ * SEC-033: Uses exact namespace component matching, not bare startsWith().
10427
+ * A prefix of "sanctuary/" matches "sanctuary/state_read" but NOT
10428
+ * "sanctuary_evil/steal_data" (no slash boundary confusion). The prefix
10429
+ * must match exactly up to its length, and the prefix must end with "/"
10430
+ * to enforce namespace boundaries (if it doesn't, we add one for safety).
10431
+ */
10432
+ shouldFilter(toolName) {
10433
+ for (const prefix of this.config.bypass_prefixes) {
10434
+ const safePrefix = prefix.endsWith("/") ? prefix : prefix + "/";
10435
+ if (toolName === safePrefix.slice(0, -1) || toolName.startsWith(safePrefix)) {
10436
+ return false;
10437
+ }
10438
+ }
10439
+ return true;
10440
+ }
10441
+ /**
10442
+ * Extract provider category from tool name.
10443
+ * Default: "tool-api". Override for specific patterns.
10444
+ */
10445
+ extractProviderCategory(toolName) {
10446
+ if (toolName.includes("inference") || toolName.includes("llm")) {
10447
+ return "inference";
10448
+ }
10449
+ if (toolName.includes("log") || toolName.includes("telemetry")) {
10450
+ return "logging";
10451
+ }
10452
+ if (toolName.includes("analytics") || toolName.includes("metric")) {
10453
+ return "analytics";
10454
+ }
10455
+ return "tool-api";
10456
+ }
10457
+ /**
10458
+ * Build filtered arguments from filter decisions.
10459
+ */
10460
+ buildFilteredArgs(originalArgs, decisions) {
10461
+ const filtered = {};
10462
+ for (const decision of decisions) {
10463
+ switch (decision.action) {
10464
+ case "allow":
10465
+ filtered[decision.field] = originalArgs[decision.field];
10466
+ break;
10467
+ case "redact":
10468
+ filtered[decision.field] = "[REDACTED]";
10469
+ break;
10470
+ case "hash":
10471
+ filtered[decision.field] = decision.hash_value;
10472
+ break;
10473
+ case "summarize":
10474
+ filtered[decision.field] = originalArgs[decision.field];
10475
+ break;
10476
+ }
10477
+ }
10478
+ return filtered;
10479
+ }
10480
+ /**
10481
+ * Set the active policy ID.
10482
+ */
10483
+ setDefaultPolicy(policyId) {
10484
+ this.config.default_policy_id = policyId;
10485
+ }
10486
+ /**
10487
+ * Get current enforcer status and stats.
10488
+ */
10489
+ getStatus() {
10490
+ return {
10491
+ enabled: this.config.enabled,
10492
+ log_only: this.config.log_only,
10493
+ default_policy_id: this.config.default_policy_id ?? null,
10494
+ stats: { ...this.stats }
10495
+ };
10496
+ }
10497
+ /**
10498
+ * Toggle enforcer enabled state.
10499
+ */
10500
+ setEnabled(enabled) {
10501
+ this.config.enabled = enabled;
10502
+ }
10503
+ /**
10504
+ * Toggle log_only mode.
10505
+ */
10506
+ setLogOnly(logOnly) {
10507
+ this.config.log_only = logOnly;
10508
+ }
10509
+ /**
10510
+ * Reset stats counters.
10511
+ */
10512
+ resetStats() {
10513
+ this.stats = {
10514
+ calls_inspected: 0,
10515
+ calls_bypassed: 0,
10516
+ fields_redacted: 0,
10517
+ fields_hashed: 0,
10518
+ fields_blocked: 0,
10519
+ calls_blocked: 0
10520
+ };
10521
+ }
10522
+ };
10523
+
8775
10524
  // src/l2-operational/context-gate-tools.ts
8776
10525
  function createContextGateTools(storage, masterKey, auditLog) {
8777
10526
  const policyStore = new ContextGatePolicyStore(storage, masterKey);
10527
+ const enforcerConfig = {
10528
+ enabled: false,
10529
+ // Off by default; agents must explicitly enable it
10530
+ bypass_prefixes: ["sanctuary/"],
10531
+ // Skip internal tools by default
10532
+ log_only: false,
10533
+ // Filter immediately
10534
+ on_deny: "block"
10535
+ // Block requests with denied fields
10536
+ };
10537
+ const enforcer = new ContextGateEnforcer(policyStore, auditLog, enforcerConfig);
8778
10538
  const tools = [
8779
10539
  // ── Set Policy ──────────────────────────────────────────────────
8780
10540
  {
@@ -9127,9 +10887,121 @@ function createContextGateTools(storage, masterKey, auditLog) {
9127
10887
  message: policies.length === 0 ? "No context-gating policies configured. Use sanctuary/context_gate_set_policy to create one." : `${policies.length} context-gating ${policies.length === 1 ? "policy" : "policies"} configured.`
9128
10888
  });
9129
10889
  }
10890
+ },
10891
+ // ── Enforcer Status ─────────────────────────────────────────────────
10892
+ {
10893
+ name: "sanctuary/context_gate_enforcer_status",
10894
+ description: "Get the status of the automatic context gate enforcer, including enabled/disabled state, log_only mode, active policy, and statistics. The enforcer automatically filters tool arguments when enabled. Use this to monitor what the enforcer has been filtering.",
10895
+ inputSchema: {
10896
+ type: "object",
10897
+ properties: {}
10898
+ },
10899
+ handler: async () => {
10900
+ const status = enforcer.getStatus();
10901
+ auditLog.append(
10902
+ "l2",
10903
+ "context_gate_enforcer_status_query",
10904
+ "system",
10905
+ {
10906
+ enabled: status.enabled,
10907
+ log_only: status.log_only,
10908
+ default_policy_id: status.default_policy_id
10909
+ }
10910
+ );
10911
+ return toolResult({
10912
+ enforcer_status: status,
10913
+ description: "The enforcer is " + (status.enabled ? "enabled" : "disabled") + ". " + (status.log_only ? "Currently in log_only mode \u2014 filtering is logged but not applied." : "Filtering is actively applied to tool arguments."),
10914
+ guidance: status.stats.calls_inspected > 0 ? `Over ${status.stats.calls_inspected} tool calls, ${status.stats.fields_redacted} sensitive fields were redacted. Use sanctuary/context_gate_enforcer_configure to adjust settings.` : "No tool calls have been inspected yet."
10915
+ });
10916
+ }
10917
+ },
10918
+ // ── Enforcer Configuration ──────────────────────────────────────────
10919
+ {
10920
+ name: "sanctuary/context_gate_enforcer_configure",
10921
+ description: "Configure the automatic context gate enforcer. Control whether it filters tool arguments, toggle log_only mode for gradual rollout, set the active policy, and choose what to do when denied fields are encountered (block the request or redact the field). Use this to enable automatic context protection.",
10922
+ inputSchema: {
10923
+ type: "object",
10924
+ properties: {
10925
+ enabled: {
10926
+ type: "boolean",
10927
+ description: "Enable or disable the automatic enforcer. When disabled, no filtering occurs. Default: leave unchanged."
10928
+ },
10929
+ log_only: {
10930
+ type: "boolean",
10931
+ description: "Enable log_only mode: filter decisions are logged but original args are passed to handlers. Useful for monitoring before enabling actual filtering. Default: leave unchanged."
10932
+ },
10933
+ default_policy_id: {
10934
+ type: "string",
10935
+ description: "Set the default context-gating policy to use for filtering. If not set, the enforcer uses built-in sensitive field patterns. Default: leave unchanged."
10936
+ },
10937
+ on_deny: {
10938
+ type: "string",
10939
+ enum: ["block", "redact"],
10940
+ description: "Action to take when a field triggers the deny action: 'block' returns an error and prevents the call, 'redact' replaces the denied field with [REDACTED] and continues. Default: leave unchanged."
10941
+ },
10942
+ reset_stats: {
10943
+ type: "boolean",
10944
+ description: "Reset the enforcer statistics counters to zero. Default: false."
10945
+ }
10946
+ }
10947
+ },
10948
+ handler: async (args) => {
10949
+ const changes = {};
10950
+ if (args.enabled !== void 0) {
10951
+ enforcer.setEnabled(args.enabled);
10952
+ changes.enabled = args.enabled;
10953
+ }
10954
+ if (args.log_only !== void 0) {
10955
+ enforcer.setLogOnly(args.log_only);
10956
+ changes.log_only = args.log_only;
10957
+ }
10958
+ if (args.default_policy_id !== void 0) {
10959
+ const policyId = args.default_policy_id;
10960
+ const policy = await policyStore.get(policyId);
10961
+ if (!policy) {
10962
+ return toolResult({
10963
+ error: "policy_not_found",
10964
+ message: `No context-gating policy found with ID "${policyId}"`
10965
+ });
10966
+ }
10967
+ enforcer.setDefaultPolicy(policyId);
10968
+ changes.default_policy_id = policyId;
10969
+ }
10970
+ if (args.on_deny !== void 0) {
10971
+ const onDeny = args.on_deny;
10972
+ if (onDeny !== "block" && onDeny !== "redact") {
10973
+ return toolResult({
10974
+ error: "invalid_on_deny",
10975
+ message: "on_deny must be 'block' or 'redact'"
10976
+ });
10977
+ }
10978
+ enforcerConfig.on_deny = onDeny;
10979
+ changes.on_deny = onDeny;
10980
+ }
10981
+ if (args.reset_stats === true) {
10982
+ enforcer.resetStats();
10983
+ changes.reset_stats = true;
10984
+ }
10985
+ const newStatus = enforcer.getStatus();
10986
+ auditLog.append(
10987
+ "l2",
10988
+ "context_gate_enforcer_configure",
10989
+ "system",
10990
+ {
10991
+ changes,
10992
+ new_status: newStatus
10993
+ }
10994
+ );
10995
+ return toolResult({
10996
+ configured: true,
10997
+ changes,
10998
+ new_status: newStatus,
10999
+ message: Object.keys(changes).length > 0 ? "Enforcer configuration updated." : "No changes made (no configuration parameters provided)."
11000
+ });
11001
+ }
9130
11002
  }
9131
11003
  ];
9132
- return { tools, policyStore };
11004
+ return { tools, policyStore, enforcer };
9133
11005
  }
9134
11006
  function checkMemoryProtection() {
9135
11007
  const checks = {
@@ -9929,11 +11801,7 @@ async function createSanctuaryServer(options) {
9929
11801
  handshakeResults
9930
11802
  );
9931
11803
  const { tools: auditTools } = createAuditTools(config);
9932
- const { tools: contextGateTools } = createContextGateTools(
9933
- storage,
9934
- masterKey,
9935
- auditLog
9936
- );
11804
+ const { tools: contextGateTools, enforcer: contextGateEnforcer } = createContextGateTools(storage, masterKey, auditLog);
9937
11805
  const hardeningTools = createL2HardeningTools(config.storage_path, auditLog);
9938
11806
  const policy = await loadPrincipalPolicy(config.storage_path);
9939
11807
  const baseline = new BaselineTracker(storage, masterKey);
@@ -9971,9 +11839,27 @@ async function createSanctuaryServer(options) {
9971
11839
  } else {
9972
11840
  approvalChannel = new StderrApprovalChannel(policy.approval_channel);
9973
11841
  }
9974
- const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog);
11842
+ const injectionDetector = new InjectionDetector({
11843
+ enabled: true,
11844
+ sensitivity: "medium",
11845
+ on_detection: "escalate"
11846
+ });
11847
+ const onInjectionAlert = dashboard ? (alert) => {
11848
+ dashboard.broadcastSSE("injection-alert", {
11849
+ tool: alert.toolName,
11850
+ confidence: alert.result.confidence,
11851
+ signals: alert.result.signals.map((s) => ({
11852
+ type: s.type,
11853
+ location: s.location,
11854
+ severity: s.severity
11855
+ })),
11856
+ recommendation: alert.result.recommendation,
11857
+ timestamp: alert.timestamp
11858
+ });
11859
+ } : void 0;
11860
+ const gate = new ApprovalGate(policy, baseline, approvalChannel, auditLog, injectionDetector, onInjectionAlert);
9975
11861
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
9976
- const allTools = [
11862
+ let allTools = [
9977
11863
  ...l1Tools,
9978
11864
  ...l2Tools,
9979
11865
  ...l3Tools,
@@ -9988,6 +11874,10 @@ async function createSanctuaryServer(options) {
9988
11874
  ...hardeningTools,
9989
11875
  manifestTool
9990
11876
  ];
11877
+ allTools = allTools.map((tool) => ({
11878
+ ...tool,
11879
+ handler: contextGateEnforcer.wrapHandler(tool.name, tool.handler)
11880
+ }));
9991
11881
  const server = createServer(allTools, { gate });
9992
11882
  await saveConfig(config);
9993
11883
  const saveBaseline = () => {
@@ -10011,6 +11901,6 @@ async function createSanctuaryServer(options) {
10011
11901
  return { server, config };
10012
11902
  }
10013
11903
 
10014
- export { ApprovalGate, AuditLog, AutoApproveChannel, BaselineTracker, TEMPLATES as CONTEXT_GATE_TEMPLATES, CallbackApprovalChannel, CommitmentStore, ContextGatePolicyStore, DashboardApprovalChannel, FederationRegistry, FilesystemStorage, MemoryStorage, PolicyStore, ReputationStore, StateStore, StderrApprovalChannel, TIER_WEIGHTS, WebhookApprovalChannel, canonicalize, classifyField, completeHandshake, computeWeightedScore, createBridgeCommitment, createPedersenCommitment, createProofOfKnowledge, createRangeProof, createSanctuaryServer, evaluateField, filterContext, generateSHR, getTemplate, initiateHandshake, listTemplateIds, loadConfig, loadPrincipalPolicy, recommendPolicy, resolveTier, respondToHandshake, signPayload, tierDistribution, verifyBridgeCommitment, verifyCompletion, verifyPedersenCommitment, verifyProofOfKnowledge, verifyRangeProof, verifySHR, verifySignature };
11904
+ export { ApprovalGate, AuditLog, AutoApproveChannel, BaselineTracker, TEMPLATES as CONTEXT_GATE_TEMPLATES, CallbackApprovalChannel, CommitmentStore, ContextGateEnforcer, ContextGatePolicyStore, DashboardApprovalChannel, FederationRegistry, FilesystemStorage, InjectionDetector, MemoryStorage, PolicyStore, ReputationStore, StateStore, StderrApprovalChannel, TIER_WEIGHTS, WebhookApprovalChannel, canonicalize, classifyField, completeHandshake, computeWeightedScore, createBridgeCommitment, createPedersenCommitment, createProofOfKnowledge, createRangeProof, createSanctuaryServer, evaluateField, filterContext, generateSHR, getTemplate, initiateHandshake, listTemplateIds, loadConfig, loadPrincipalPolicy, recommendPolicy, resolveTier, respondToHandshake, signPayload, tierDistribution, verifyBridgeCommitment, verifyCompletion, verifyPedersenCommitment, verifyProofOfKnowledge, verifyRangeProof, verifySHR, verifySignature };
10015
11905
  //# sourceMappingURL=index.js.map
10016
11906
  //# sourceMappingURL=index.js.map