@sanctuary-framework/mcp-server 0.5.14 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -12,13 +12,16 @@ var hmac = require('@noble/hashes/hmac');
12
12
  var ed25519 = require('@noble/curves/ed25519');
13
13
  var hashWasm = require('hash-wasm');
14
14
  var hkdf = require('@noble/hashes/hkdf');
15
- var index_js = require('@modelcontextprotocol/sdk/server/index.js');
15
+ var index_js$1 = require('@modelcontextprotocol/sdk/server/index.js');
16
16
  var types_js = require('@modelcontextprotocol/sdk/types.js');
17
17
  var http = require('http');
18
18
  var https = require('https');
19
19
  var fs = require('fs');
20
20
  var child_process = require('child_process');
21
21
  var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
22
+ var index_js = require('@modelcontextprotocol/sdk/client/index.js');
23
+ var stdio_js$1 = require('@modelcontextprotocol/sdk/client/stdio.js');
24
+ var sse_js = require('@modelcontextprotocol/sdk/client/sse.js');
22
25
 
23
26
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
24
27
  var __defProp = Object.defineProperty;
@@ -816,7 +819,7 @@ function checkType(field, value, schema) {
816
819
  }
817
820
  function createServer(tools, options) {
818
821
  const gate = options?.gate;
819
- const server = new index_js.Server(
822
+ const server = new index_js$1.Server(
820
823
  {
821
824
  name: "sanctuary-mcp-server",
822
825
  version: PKG_VERSION2
@@ -1537,6 +1540,9 @@ var init_audit_log = __esm({
1537
1540
  }
1538
1541
  });
1539
1542
  function extractOperationName(toolName) {
1543
+ if (toolName.startsWith("proxy/")) {
1544
+ return toolName;
1545
+ }
1540
1546
  return toolName.startsWith("sanctuary/") ? toolName.slice("sanctuary/".length) : toolName;
1541
1547
  }
1542
1548
  function parsePolicy(content) {
@@ -1642,6 +1648,7 @@ tier1_always_approve:
1642
1648
  - bootstrap_provide_guarantee
1643
1649
  - reputation_publish
1644
1650
  - sovereignty_profile_update
1651
+ - governor_reset
1645
1652
 
1646
1653
  # \u2500\u2500\u2500 Tier 2: Behavioral Anomaly Detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1647
1654
  # Triggers approval when agent behavior deviates from its baseline.
@@ -1706,7 +1713,7 @@ tier3_always_allow:
1706
1713
  - bridge_attest
1707
1714
  - dashboard_open
1708
1715
  - sovereignty_profile_get
1709
- - sovereignty_profile_generate_prompt
1716
+ - governor_status
1710
1717
 
1711
1718
  # \u2500\u2500\u2500 Approval Channel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1712
1719
  # How Sanctuary reaches you when approval is needed.
@@ -1762,8 +1769,10 @@ var init_loader = __esm({
1762
1769
  "decommission_certificate",
1763
1770
  "reputation_publish",
1764
1771
  // SEC-039: Explicit Tier 1 — sends data to external API
1765
- "sovereignty_profile_update"
1772
+ "sovereignty_profile_update",
1766
1773
  // Changes enforcement behavior — always requires approval
1774
+ "governor_reset"
1775
+ // Clears all runtime governance state — always requires approval
1767
1776
  ],
1768
1777
  tier2_anomaly: DEFAULT_TIER2,
1769
1778
  tier3_always_allow: [
@@ -1819,7 +1828,9 @@ var init_loader = __esm({
1819
1828
  "dashboard_open",
1820
1829
  // SEC-039: Explicit Tier 3 — only generates a URL
1821
1830
  "sovereignty_profile_get",
1822
- "sovereignty_profile_generate_prompt"
1831
+ "sovereignty_profile_generate_prompt",
1832
+ // Agent needs its own config to generate system prompt
1833
+ "governor_status"
1823
1834
  ],
1824
1835
  approval_channel: DEFAULT_CHANNEL
1825
1836
  };
@@ -2333,7 +2344,7 @@ function generateLoginHTML(options) {
2333
2344
  if (response.ok) {
2334
2345
  const data = await response.json();
2335
2346
  sessionStorage.setItem('authToken', token);
2336
- window.location.href = '/dashboard';
2347
+ window.location.href = '/'; // Dashboard is served at root path
2337
2348
  } else if (response.status === 401) {
2338
2349
  showError('Invalid token. Please check and try again.');
2339
2350
  } else {
@@ -3072,6 +3083,17 @@ function generateDashboardHTML(options) {
3072
3083
  display: flex;
3073
3084
  flex-direction: column;
3074
3085
  gap: 8px;
3086
+ transition: border-color 0.2s;
3087
+ }
3088
+
3089
+ .profile-card.active {
3090
+ border-color: var(--green);
3091
+ }
3092
+
3093
+ .profile-card-header {
3094
+ display: flex;
3095
+ justify-content: space-between;
3096
+ align-items: center;
3075
3097
  }
3076
3098
 
3077
3099
  .profile-card-name {
@@ -3086,6 +3108,53 @@ function generateDashboardHTML(options) {
3086
3108
  line-height: 1.4;
3087
3109
  }
3088
3110
 
3111
+ /* Toggle switch */
3112
+ .toggle-switch {
3113
+ position: relative;
3114
+ width: 36px;
3115
+ height: 20px;
3116
+ flex-shrink: 0;
3117
+ }
3118
+
3119
+ .toggle-switch input {
3120
+ opacity: 0;
3121
+ width: 0;
3122
+ height: 0;
3123
+ }
3124
+
3125
+ .toggle-slider {
3126
+ position: absolute;
3127
+ cursor: pointer;
3128
+ top: 0;
3129
+ left: 0;
3130
+ right: 0;
3131
+ bottom: 0;
3132
+ background-color: var(--border);
3133
+ border-radius: 10px;
3134
+ transition: background-color 0.2s;
3135
+ }
3136
+
3137
+ .toggle-slider::before {
3138
+ content: "";
3139
+ position: absolute;
3140
+ height: 14px;
3141
+ width: 14px;
3142
+ left: 3px;
3143
+ bottom: 3px;
3144
+ background-color: var(--text-secondary);
3145
+ border-radius: 50%;
3146
+ transition: transform 0.2s, background-color 0.2s;
3147
+ }
3148
+
3149
+ .toggle-switch input:checked + .toggle-slider {
3150
+ background-color: rgba(63, 185, 80, 0.3);
3151
+ }
3152
+
3153
+ .toggle-switch input:checked + .toggle-slider::before {
3154
+ transform: translateX(16px);
3155
+ background-color: var(--green);
3156
+ }
3157
+
3089
3158
  .profile-badge {
3090
3159
  display: inline-flex;
3091
3160
  align-items: center;
@@ -3107,22 +3176,159 @@ function generateDashboardHTML(options) {
3107
3176
  color: var(--text-secondary);
3108
3177
  }
3109
3178
 
3179
+ .profile-card-actions {
3180
+ display: flex;
3181
+ gap: 6px;
3182
+ margin-top: 4px;
3183
+ }
3184
+
3185
+ .config-btn {
3186
+ padding: 3px 8px;
3187
+ border: 1px solid var(--border);
3188
+ border-radius: 4px;
3189
+ background-color: transparent;
3190
+ color: var(--text-secondary);
3191
+ font-size: 10px;
3192
+ cursor: pointer;
3193
+ transition: color 0.2s, border-color 0.2s;
3194
+ }
3195
+
3196
+ .config-btn:hover {
3197
+ color: var(--blue);
3198
+ border-color: var(--blue);
3199
+ }
3200
+
3201
+ /* Configuration panels */
3202
+ .config-panel {
3203
+ display: none;
3204
+ margin-top: 8px;
3205
+ padding: 10px;
3206
+ background-color: var(--surface);
3207
+ border: 1px solid var(--border);
3208
+ border-radius: 4px;
3209
+ font-size: 11px;
3210
+ }
3211
+
3212
+ .config-panel.open {
3213
+ display: block;
3214
+ }
3215
+
3216
+ .config-panel-title {
3217
+ font-size: 11px;
3218
+ font-weight: 600;
3219
+ color: var(--text-primary);
3220
+ margin-bottom: 8px;
3221
+ }
3222
+
3223
+ .config-row {
3224
+ display: flex;
3225
+ align-items: center;
3226
+ gap: 8px;
3227
+ margin-bottom: 6px;
3228
+ }
3229
+
3230
+ .config-label {
3231
+ font-size: 11px;
3232
+ color: var(--text-secondary);
3233
+ min-width: 80px;
3234
+ }
3235
+
3236
+ .config-select, .config-input {
3237
+ background-color: var(--bg);
3238
+ border: 1px solid var(--border);
3239
+ border-radius: 4px;
3240
+ color: var(--text-primary);
3241
+ font-size: 11px;
3242
+ padding: 4px 8px;
3243
+ }
3244
+
3245
+ .config-info {
3246
+ font-size: 11px;
3247
+ color: var(--text-secondary);
3248
+ line-height: 1.5;
3249
+ }
3250
+
3251
+ .sensitivity-slider {
3252
+ display: flex;
3253
+ gap: 4px;
3254
+ }
3255
+
3256
+ .sensitivity-option {
3257
+ padding: 3px 10px;
3258
+ border: 1px solid var(--border);
3259
+ border-radius: 4px;
3260
+ background-color: transparent;
3261
+ color: var(--text-secondary);
3262
+ font-size: 10px;
3263
+ cursor: pointer;
3264
+ transition: all 0.2s;
3265
+ }
3266
+
3267
+ .sensitivity-option.selected {
3268
+ background-color: rgba(88, 166, 255, 0.15);
3269
+ color: var(--blue);
3270
+ border-color: var(--blue);
3271
+ }
3272
+
3273
+ .sensitivity-option:hover:not(.selected) {
3274
+ border-color: var(--text-secondary);
3275
+ }
3276
+
3277
+ /* Prompt section */
3110
3278
  .prompt-section {
3111
- margin-top: 12px;
3279
+ margin-top: 16px;
3112
3280
  }
3113
3281
 
3114
- .prompt-textarea {
3115
- width: 100%;
3116
- min-height: 120px;
3282
+ .prompt-display {
3283
+ position: relative;
3117
3284
  background-color: var(--bg);
3118
3285
  border: 1px solid var(--border);
3119
3286
  border-radius: 6px;
3287
+ margin-top: 8px;
3288
+ display: none;
3289
+ }
3290
+
3291
+ .prompt-display.visible {
3292
+ display: block;
3293
+ }
3294
+
3295
+ .prompt-display-header {
3296
+ display: flex;
3297
+ justify-content: space-between;
3298
+ align-items: center;
3299
+ padding: 8px 12px;
3300
+ border-bottom: 1px solid var(--border);
3301
+ }
3302
+
3303
+ .prompt-token-count {
3304
+ font-size: 11px;
3305
+ color: var(--text-secondary);
3306
+ }
3307
+
3308
+ .prompt-copy-btn {
3309
+ padding: 4px 10px;
3310
+ border: 1px solid var(--border);
3311
+ border-radius: 4px;
3312
+ background-color: var(--surface);
3120
3313
  color: var(--text-primary);
3314
+ font-size: 11px;
3315
+ cursor: pointer;
3316
+ transition: border-color 0.2s;
3317
+ }
3318
+
3319
+ .prompt-copy-btn:hover {
3320
+ border-color: var(--blue);
3321
+ }
3322
+
3323
+ .prompt-content {
3324
+ max-height: 300px;
3325
+ overflow-y: auto;
3326
+ padding: 12px;
3121
3327
  font-family: 'JetBrains Mono', monospace;
3122
3328
  font-size: 12px;
3123
- padding: 12px;
3124
- resize: vertical;
3125
- margin-top: 8px;
3329
+ line-height: 1.6;
3330
+ white-space: pre-wrap;
3331
+ color: var(--text-primary);
3126
3332
  }
3127
3333
 
3128
3334
  .prompt-actions {
@@ -3509,37 +3715,173 @@ function generateDashboardHTML(options) {
3509
3715
  </div>
3510
3716
  <div class="profile-cards" id="profile-cards">
3511
3717
  <div class="profile-card" data-feature="audit_logging">
3512
- <div class="profile-card-name">Audit Logging</div>
3718
+ <div class="profile-card-header">
3719
+ <div class="profile-card-name">Audit Logging</div>
3720
+ <label class="toggle-switch">
3721
+ <input type="checkbox" id="toggle-audit_logging" data-feature="audit_logging">
3722
+ <span class="toggle-slider"></span>
3723
+ </label>
3724
+ </div>
3513
3725
  <div class="profile-badge disabled" id="badge-audit_logging">OFF</div>
3514
3726
  <div class="profile-card-desc">Encrypted audit trail of all tool calls</div>
3727
+ <div class="profile-card-actions">
3728
+ <button class="config-btn" data-config="audit_logging">Configure</button>
3729
+ </div>
3730
+ <div class="config-panel" id="config-audit_logging">
3731
+ <div class="config-info">Audit logging is always-on when enabled. All tool calls, gate decisions, and profile changes are recorded to an encrypted audit trail. No additional configuration needed.</div>
3732
+ </div>
3515
3733
  </div>
3516
3734
  <div class="profile-card" data-feature="injection_detection">
3517
- <div class="profile-card-name">Injection Detection</div>
3735
+ <div class="profile-card-header">
3736
+ <div class="profile-card-name">Injection Detection</div>
3737
+ <label class="toggle-switch">
3738
+ <input type="checkbox" id="toggle-injection_detection" data-feature="injection_detection">
3739
+ <span class="toggle-slider"></span>
3740
+ </label>
3741
+ </div>
3518
3742
  <div class="profile-badge disabled" id="badge-injection_detection">OFF</div>
3519
3743
  <div class="profile-card-desc">Scans tool arguments for prompt injection</div>
3744
+ <div class="profile-card-actions">
3745
+ <button class="config-btn" data-config="injection_detection">Configure</button>
3746
+ </div>
3747
+ <div class="config-panel" id="config-injection_detection">
3748
+ <div class="config-panel-title">Sensitivity</div>
3749
+ <div class="sensitivity-slider">
3750
+ <button class="sensitivity-option" data-sensitivity="low">Low</button>
3751
+ <button class="sensitivity-option selected" data-sensitivity="medium">Medium</button>
3752
+ <button class="sensitivity-option" data-sensitivity="high">High</button>
3753
+ </div>
3754
+ </div>
3520
3755
  </div>
3521
3756
  <div class="profile-card" data-feature="context_gating">
3522
- <div class="profile-card-name">Context Gating</div>
3757
+ <div class="profile-card-header">
3758
+ <div class="profile-card-name">Context Gating</div>
3759
+ <label class="toggle-switch">
3760
+ <input type="checkbox" id="toggle-context_gating" data-feature="context_gating">
3761
+ <span class="toggle-slider"></span>
3762
+ </label>
3763
+ </div>
3523
3764
  <div class="profile-badge disabled" id="badge-context_gating">OFF</div>
3524
3765
  <div class="profile-card-desc">Controls context flow to remote providers</div>
3766
+ <div class="profile-card-actions">
3767
+ <button class="config-btn" data-config="context_gating">Configure</button>
3768
+ </div>
3769
+ <div class="config-panel" id="config-context_gating">
3770
+ <div class="config-panel-title">Active Policy</div>
3771
+ <div class="config-row">
3772
+ <span class="config-label">Policy ID:</span>
3773
+ <select class="config-select" id="config-context-policy">
3774
+ <option value="">None selected</option>
3775
+ </select>
3776
+ </div>
3777
+ <div class="config-info" style="margin-top: 6px;">Use MCP tool <code style="color: var(--blue);">sanctuary/context_gate_set_policy</code> or <code style="color: var(--blue);">sanctuary/context_gate_apply_template</code> to create policies.</div>
3778
+ </div>
3525
3779
  </div>
3526
3780
  <div class="profile-card" data-feature="approval_gate">
3527
- <div class="profile-card-name">Approval Gates</div>
3781
+ <div class="profile-card-header">
3782
+ <div class="profile-card-name">Approval Gates</div>
3783
+ <label class="toggle-switch">
3784
+ <input type="checkbox" id="toggle-approval_gate" data-feature="approval_gate">
3785
+ <span class="toggle-slider"></span>
3786
+ </label>
3787
+ </div>
3528
3788
  <div class="profile-badge disabled" id="badge-approval_gate">OFF</div>
3529
3789
  <div class="profile-card-desc">Human approval for high-risk operations</div>
3790
+ <div class="profile-card-actions">
3791
+ <button class="config-btn" data-config="approval_gate">Configure</button>
3792
+ </div>
3793
+ <div class="config-panel" id="config-approval_gate">
3794
+ <div class="config-panel-title">Tier Assignments</div>
3795
+ <div class="config-info">
3796
+ <strong style="color: var(--red);">Tier 1 (always approve):</strong> export, import, key rotation, deletion<br>
3797
+ <strong style="color: var(--amber);">Tier 2 (approve on anomaly):</strong> new namespaces, unfamiliar counterparties, frequency spikes<br>
3798
+ <strong style="color: var(--green);">Tier 3 (auto-allow):</strong> standard operations, queries, reads
3799
+ </div>
3800
+ </div>
3530
3801
  </div>
3531
3802
  <div class="profile-card" data-feature="zk_proofs">
3532
- <div class="profile-card-name">ZK Proofs</div>
3803
+ <div class="profile-card-header">
3804
+ <div class="profile-card-name">ZK Proofs</div>
3805
+ <label class="toggle-switch">
3806
+ <input type="checkbox" id="toggle-zk_proofs" data-feature="zk_proofs">
3807
+ <span class="toggle-slider"></span>
3808
+ </label>
3809
+ </div>
3533
3810
  <div class="profile-badge disabled" id="badge-zk_proofs">OFF</div>
3534
3811
  <div class="profile-card-desc">Prove claims without revealing data</div>
3812
+ <div class="profile-card-actions">
3813
+ <button class="config-btn" data-config="zk_proofs">Configure</button>
3814
+ </div>
3815
+ <div class="config-panel" id="config-zk_proofs">
3816
+ <div class="config-info">Zero-knowledge proofs use Pedersen commitments on Ristretto255 and Schnorr proofs. No additional configuration needed. Available tools: <code style="color: var(--blue);">sanctuary/zk_commit</code>, <code style="color: var(--blue);">sanctuary/zk_prove</code>, <code style="color: var(--blue);">sanctuary/zk_range_prove</code>.</div>
3817
+ </div>
3535
3818
  </div>
3536
3819
  </div>
3537
3820
  <div class="prompt-section">
3538
3821
  <div class="prompt-actions">
3539
3822
  <button class="prompt-btn primary" id="generate-prompt-btn">Generate System Prompt</button>
3540
- <button class="prompt-btn" id="copy-prompt-btn" style="display:none;">Copy</button>
3541
3823
  </div>
3542
- <textarea class="prompt-textarea" id="system-prompt-output" readonly style="display:none;" placeholder="Click 'Generate System Prompt' to create an agent instruction snippet..."></textarea>
3824
+ <div class="prompt-display" id="prompt-display">
3825
+ <div class="prompt-display-header">
3826
+ <span class="prompt-token-count" id="prompt-token-count"></span>
3827
+ <button class="prompt-copy-btn" id="copy-prompt-btn">Copy to Clipboard</button>
3828
+ </div>
3829
+ <div class="prompt-content" id="system-prompt-output"></div>
3830
+ </div>
3831
+ </div>
3832
+ </div>
3833
+
3834
+ <!-- Upstream Servers Panel -->
3835
+ <div class="profile-panel" id="proxy-servers-panel">
3836
+ <div class="panel-header">
3837
+ <div class="panel-title">Upstream Servers</div>
3838
+ <button class="panel-action" id="add-proxy-server-btn">+ Add Server</button>
3839
+ </div>
3840
+ <div id="proxy-servers-list">
3841
+ <div class="empty-state">No upstream servers configured</div>
3842
+ </div>
3843
+
3844
+ <!-- Add Server Form (hidden by default) -->
3845
+ <div id="add-server-form" style="display: none; padding: 16px; border-top: 1px solid var(--border);">
3846
+ <div style="margin-bottom: 12px;">
3847
+ <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Server Name</label>
3848
+ <input type="text" id="new-server-name" placeholder="e.g., filesystem" style="width: 100%; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px;">
3849
+ </div>
3850
+ <div style="margin-bottom: 12px;">
3851
+ <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Transport Type</label>
3852
+ <select id="new-server-transport" style="width: 100%; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px;">
3853
+ <option value="stdio">stdio</option>
3854
+ <option value="sse">SSE</option>
3855
+ </select>
3856
+ </div>
3857
+ <div id="stdio-fields">
3858
+ <div style="margin-bottom: 12px;">
3859
+ <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Command</label>
3860
+ <input type="text" id="new-server-command" placeholder="e.g., npx -y @modelcontextprotocol/server-filesystem" style="width: 100%; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px;">
3861
+ </div>
3862
+ <div style="margin-bottom: 12px;">
3863
+ <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Arguments (comma-separated)</label>
3864
+ <input type="text" id="new-server-args" placeholder="e.g., /Users/me/allowed-dir" style="width: 100%; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px;">
3865
+ </div>
3866
+ </div>
3867
+ <div id="sse-fields" style="display: none;">
3868
+ <div style="margin-bottom: 12px;">
3869
+ <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Server URL</label>
3870
+ <input type="text" id="new-server-url" placeholder="e.g., http://localhost:3001/sse" style="width: 100%; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px;">
3871
+ </div>
3872
+ </div>
3873
+ <div style="margin-bottom: 12px;">
3874
+ <label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Default Tier</label>
3875
+ <select id="new-server-tier" style="width: 100%; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary); font-size: 13px;">
3876
+ <option value="1">Tier 1 (Always approve)</option>
3877
+ <option value="2" selected>Tier 2 (Anomaly detection)</option>
3878
+ <option value="3">Tier 3 (Always allow)</option>
3879
+ </select>
3880
+ </div>
3881
+ <div style="display: flex; gap: 8px;">
3882
+ <button id="save-server-btn" style="flex: 1; padding: 8px; background: var(--green); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">Save</button>
3883
+ <button id="cancel-server-btn" style="flex: 1; padding: 8px; background: var(--surface); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 13px;">Cancel</button>
3884
+ </div>
3543
3885
  </div>
3544
3886
  </div>
3545
3887
 
@@ -4030,8 +4372,34 @@ function generateDashboardHTML(options) {
4030
4372
  removePendingRequest(data.requestId);
4031
4373
  });
4032
4374
 
4033
- eventSource.addEventListener('sovereignty-profile-update', () => {
4034
- updateSovereigntyProfile();
4375
+ eventSource.addEventListener('sovereignty-profile-update', (e) => {
4376
+ try {
4377
+ const data = JSON.parse(e.data);
4378
+ if (data.profile) {
4379
+ applyProfileToUI(data.profile);
4380
+ }
4381
+ if (data.system_prompt) {
4382
+ apiState.systemPrompt = data.system_prompt;
4383
+ updatePromptDisplay(data.system_prompt);
4384
+ }
4385
+ } catch (err) {
4386
+ // Fallback to full refresh
4387
+ updateSovereigntyProfile();
4388
+ }
4389
+ });
4390
+
4391
+ eventSource.addEventListener('proxy-server-status', (e) => {
4392
+ try {
4393
+ const data = JSON.parse(e.data);
4394
+ updateProxyServerStatus(data.server, data.state, data.tool_count, data.error);
4395
+ } catch (err) {
4396
+ // Fallback to full refresh
4397
+ loadProxyServers();
4398
+ }
4399
+ });
4400
+
4401
+ eventSource.addEventListener('proxy-servers-update', () => {
4402
+ loadProxyServers();
4035
4403
  });
4036
4404
 
4037
4405
  eventSource.onerror = () => {
@@ -4049,7 +4417,7 @@ function generateDashboardHTML(options) {
4049
4417
 
4050
4418
  const feed = document.getElementById('activity-feed');
4051
4419
  const html = \`
4052
- <div class="activity-item \${item.type}">
4420
+ <div class="activity-item \${esc(item.type)}">
4053
4421
  <div class="activity-type">\${esc(item.title)}</div>
4054
4422
  <div class="activity-content">\${esc(item.content)}</div>
4055
4423
  <div class="activity-time">\${formatTime(item.timestamp)}</div>
@@ -4191,26 +4559,16 @@ function generateDashboardHTML(options) {
4191
4559
  });
4192
4560
 
4193
4561
  // Sovereignty Profile
4562
+ let profileToggleLock = false;
4563
+
4194
4564
  async function updateSovereigntyProfile() {
4195
4565
  try {
4196
4566
  const data = await fetchAPI('/api/sovereignty-profile');
4197
4567
  if (data && data.profile) {
4198
- const features = data.profile.features;
4199
- for (const [key, value] of Object.entries(features)) {
4200
- const badge = document.getElementById('badge-' + key);
4201
- if (badge) {
4202
- const enabled = value && value.enabled;
4203
- badge.textContent = enabled ? 'ON' : 'OFF';
4204
- badge.className = 'profile-badge ' + (enabled ? 'enabled' : 'disabled');
4205
- }
4206
- }
4207
- const updatedEl = document.getElementById('profile-updated-at');
4208
- if (updatedEl && data.profile.updated_at) {
4209
- updatedEl.textContent = 'Updated: ' + new Date(data.profile.updated_at).toLocaleString();
4210
- }
4211
- // Cache the prompt
4568
+ applyProfileToUI(data.profile);
4212
4569
  if (data.system_prompt) {
4213
4570
  apiState.systemPrompt = data.system_prompt;
4571
+ updatePromptDisplay(data.system_prompt);
4214
4572
  }
4215
4573
  }
4216
4574
  } catch (e) {
@@ -4218,69 +4576,476 @@ function generateDashboardHTML(options) {
4218
4576
  }
4219
4577
  }
4220
4578
 
4221
- document.getElementById('generate-prompt-btn').addEventListener('click', async () => {
4222
- const data = await fetchAPI('/api/sovereignty-profile');
4223
- if (data && data.system_prompt) {
4224
- const textarea = document.getElementById('system-prompt-output');
4225
- const copyBtn = document.getElementById('copy-prompt-btn');
4226
- textarea.value = data.system_prompt;
4227
- textarea.style.display = 'block';
4228
- copyBtn.style.display = 'inline-flex';
4229
- }
4230
- });
4579
+ function applyProfileToUI(profile) {
4580
+ const features = profile.features;
4581
+ for (const [key, value] of Object.entries(features)) {
4582
+ const enabled = value && value.enabled;
4231
4583
 
4232
- document.getElementById('copy-prompt-btn').addEventListener('click', async () => {
4233
- const textarea = document.getElementById('system-prompt-output');
4234
- try {
4235
- await navigator.clipboard.writeText(textarea.value);
4236
- const btn = document.getElementById('copy-prompt-btn');
4237
- const original = btn.textContent;
4238
- btn.textContent = 'Copied!';
4239
- setTimeout(() => { btn.textContent = original; }, 2000);
4240
- } catch (err) {
4241
- console.error('Copy failed:', err);
4242
- }
4243
- });
4584
+ // Update badge
4585
+ const badge = document.getElementById('badge-' + key);
4586
+ if (badge) {
4587
+ badge.textContent = enabled ? 'ON' : 'OFF';
4588
+ badge.className = 'profile-badge ' + (enabled ? 'enabled' : 'disabled');
4589
+ }
4244
4590
 
4245
- // Initialize
4246
- async function initialize() {
4247
- if (!AUTH_TOKEN) {
4248
- redirectToLogin();
4249
- return;
4250
- }
4591
+ // Update toggle (without triggering change event)
4592
+ const toggle = document.getElementById('toggle-' + key);
4593
+ if (toggle && toggle.checked !== enabled) {
4594
+ profileToggleLock = true;
4595
+ toggle.checked = enabled;
4596
+ profileToggleLock = false;
4597
+ }
4251
4598
 
4252
- // Initial data fetch
4253
- await Promise.all([
4254
- updateSovereignty(),
4255
- updateIdentity(),
4256
- updateHandshakes(),
4257
- updateSHR(),
4258
- updateStatus(),
4259
- updateSovereigntyProfile(),
4260
- ]);
4599
+ // Update card active state
4600
+ const card = document.querySelector('[data-feature="' + key + '"]');
4601
+ if (card) {
4602
+ card.classList.toggle('active', enabled);
4603
+ }
4261
4604
 
4262
- // Setup SSE for real-time updates
4263
- setupSSE();
4605
+ // Feature-specific config UI updates
4606
+ if (key === 'injection_detection' && value.sensitivity) {
4607
+ document.querySelectorAll('#config-injection_detection .sensitivity-option').forEach(function(btn) {
4608
+ btn.classList.toggle('selected', btn.getAttribute('data-sensitivity') === value.sensitivity);
4609
+ });
4610
+ }
4264
4611
 
4265
- // Refresh status periodically
4266
- setInterval(updateStatus, 30000);
4612
+ if (key === 'context_gating' && value.policy_id) {
4613
+ const sel = document.getElementById('config-context-policy');
4614
+ if (sel) {
4615
+ // Add the policy as an option if not present
4616
+ let found = false;
4617
+ for (let i = 0; i < sel.options.length; i++) {
4618
+ if (sel.options[i].value === value.policy_id) { found = true; break; }
4619
+ }
4620
+ if (!found) {
4621
+ const opt = document.createElement('option');
4622
+ opt.value = value.policy_id;
4623
+ opt.textContent = value.policy_id;
4624
+ sel.appendChild(opt);
4625
+ }
4626
+ sel.value = value.policy_id;
4627
+ }
4628
+ }
4629
+ }
4630
+
4631
+ const updatedEl = document.getElementById('profile-updated-at');
4632
+ if (updatedEl && profile.updated_at) {
4633
+ updatedEl.textContent = 'Updated: ' + new Date(profile.updated_at).toLocaleString();
4634
+ }
4267
4635
  }
4268
4636
 
4269
- // Start
4270
- initialize();
4271
- </script>
4272
- </body>
4273
- </html>`;
4274
- }
4275
- var init_dashboard_html = __esm({
4276
- "src/principal-policy/dashboard-html.ts"() {
4277
- }
4278
- });
4637
+ function updatePromptDisplay(promptText) {
4638
+ const display = document.getElementById('prompt-display');
4639
+ const content = document.getElementById('system-prompt-output');
4640
+ const tokenCount = document.getElementById('prompt-token-count');
4641
+ if (!display || !content) return;
4642
+
4643
+ if (promptText) {
4644
+ content.textContent = promptText;
4645
+ // Rough token estimate: word count * 1.3
4646
+ const words = promptText.split(/\\s+/).filter(function(w) { return w.length > 0; }).length;
4647
+ const tokens = Math.round(words * 1.3);
4648
+ tokenCount.textContent = '~' + tokens + ' tokens';
4649
+ display.classList.add('visible');
4650
+ }
4651
+ }
4652
+
4653
+ // Toggle handlers
4654
+ async function handleToggle(feature, enabled) {
4655
+ if (profileToggleLock) return;
4656
+ const payload = {};
4657
+ payload[feature] = { enabled: enabled };
4658
+
4659
+ try {
4660
+ const response = await fetch(API_BASE + '/api/sovereignty-profile', {
4661
+ method: 'POST',
4662
+ headers: {
4663
+ 'Authorization': 'Bearer ' + AUTH_TOKEN,
4664
+ 'Content-Type': 'application/json',
4665
+ },
4666
+ body: JSON.stringify(payload),
4667
+ });
4668
+
4669
+ if (response.status === 401) {
4670
+ redirectToLogin();
4671
+ return;
4672
+ }
4673
+
4674
+ if (response.ok) {
4675
+ const data = await response.json();
4676
+ if (data.profile) {
4677
+ applyProfileToUI(data.profile);
4678
+ }
4679
+ if (data.system_prompt) {
4680
+ apiState.systemPrompt = data.system_prompt;
4681
+ updatePromptDisplay(data.system_prompt);
4682
+ }
4683
+ } else {
4684
+ // Revert toggle on failure
4685
+ const toggle = document.getElementById('toggle-' + feature);
4686
+ if (toggle) {
4687
+ profileToggleLock = true;
4688
+ toggle.checked = !enabled;
4689
+ profileToggleLock = false;
4690
+ }
4691
+ }
4692
+ } catch (err) {
4693
+ console.error('Toggle update failed:', err);
4694
+ // Revert toggle
4695
+ const toggle = document.getElementById('toggle-' + feature);
4696
+ if (toggle) {
4697
+ profileToggleLock = true;
4698
+ toggle.checked = !enabled;
4699
+ profileToggleLock = false;
4700
+ }
4701
+ }
4702
+ }
4703
+
4704
+ // Wire up toggle switches
4705
+ document.querySelectorAll('.toggle-switch input').forEach(function(toggle) {
4706
+ toggle.addEventListener('change', function() {
4707
+ const feature = this.getAttribute('data-feature');
4708
+ handleToggle(feature, this.checked);
4709
+ });
4710
+ });
4711
+
4712
+ // Wire up configure buttons
4713
+ document.querySelectorAll('.config-btn').forEach(function(btn) {
4714
+ btn.addEventListener('click', function() {
4715
+ const feature = this.getAttribute('data-config');
4716
+ const panel = document.getElementById('config-' + feature);
4717
+ if (panel) {
4718
+ panel.classList.toggle('open');
4719
+ this.textContent = panel.classList.contains('open') ? 'Close' : 'Configure';
4720
+ }
4721
+ });
4722
+ });
4723
+
4724
+ // Injection sensitivity handler
4725
+ document.querySelectorAll('#config-injection_detection .sensitivity-option').forEach(function(btn) {
4726
+ btn.addEventListener('click', async function() {
4727
+ const sensitivity = this.getAttribute('data-sensitivity');
4728
+ const response = await fetch(API_BASE + '/api/sovereignty-profile', {
4729
+ method: 'POST',
4730
+ headers: {
4731
+ 'Authorization': 'Bearer ' + AUTH_TOKEN,
4732
+ 'Content-Type': 'application/json',
4733
+ },
4734
+ body: JSON.stringify({ injection_detection: { sensitivity: sensitivity } }),
4735
+ });
4736
+ if (response.ok) {
4737
+ document.querySelectorAll('#config-injection_detection .sensitivity-option').forEach(function(b) {
4738
+ b.classList.toggle('selected', b.getAttribute('data-sensitivity') === sensitivity);
4739
+ });
4740
+ const data = await response.json();
4741
+ if (data.profile) applyProfileToUI(data.profile);
4742
+ if (data.system_prompt) {
4743
+ apiState.systemPrompt = data.system_prompt;
4744
+ updatePromptDisplay(data.system_prompt);
4745
+ }
4746
+ }
4747
+ });
4748
+ });
4749
+
4750
+ // Context gating policy selector handler
4751
+ document.getElementById('config-context-policy').addEventListener('change', async function() {
4752
+ const policyId = this.value;
4753
+ const response = await fetch(API_BASE + '/api/sovereignty-profile', {
4754
+ method: 'POST',
4755
+ headers: {
4756
+ 'Authorization': 'Bearer ' + AUTH_TOKEN,
4757
+ 'Content-Type': 'application/json',
4758
+ },
4759
+ body: JSON.stringify({ context_gating: { policy_id: policyId } }),
4760
+ });
4761
+ if (response.ok) {
4762
+ const data = await response.json();
4763
+ if (data.profile) applyProfileToUI(data.profile);
4764
+ if (data.system_prompt) {
4765
+ apiState.systemPrompt = data.system_prompt;
4766
+ updatePromptDisplay(data.system_prompt);
4767
+ }
4768
+ }
4769
+ });
4770
+
4771
+ // Generate prompt button
4772
+ document.getElementById('generate-prompt-btn').addEventListener('click', async () => {
4773
+ const data = await fetchAPI('/api/sovereignty-profile');
4774
+ if (data && data.system_prompt) {
4775
+ apiState.systemPrompt = data.system_prompt;
4776
+ updatePromptDisplay(data.system_prompt);
4777
+ }
4778
+ });
4779
+
4780
+ // Copy prompt button
4781
+ document.getElementById('copy-prompt-btn').addEventListener('click', async () => {
4782
+ const content = document.getElementById('system-prompt-output');
4783
+ if (!content || !content.textContent) return;
4784
+ try {
4785
+ await navigator.clipboard.writeText(content.textContent);
4786
+ const btn = document.getElementById('copy-prompt-btn');
4787
+ const original = btn.textContent;
4788
+ btn.textContent = 'Copied!';
4789
+ setTimeout(() => { btn.textContent = original; }, 2000);
4790
+ } catch (err) {
4791
+ console.error('Copy failed:', err);
4792
+ }
4793
+ });
4794
+
4795
+ // \u2500\u2500 Proxy Server Management \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4796
+
4797
+ let proxyServers = [];
4798
+
4799
+ async function loadProxyServers() {
4800
+ try {
4801
+ const resp = await fetch(API_BASE + '/api/proxy/servers', {
4802
+ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN },
4803
+ });
4804
+ if (!resp.ok) return;
4805
+ const data = await resp.json();
4806
+ proxyServers = data.servers || [];
4807
+ renderProxyServers();
4808
+ } catch (err) {
4809
+ // Proxy endpoint may not be available
4810
+ }
4811
+ }
4812
+
4813
+ function renderProxyServers() {
4814
+ const container = document.getElementById('proxy-servers-list');
4815
+ if (!container) return;
4816
+
4817
+ if (proxyServers.length === 0) {
4818
+ container.innerHTML = '<div class="empty-state">No upstream servers configured</div>';
4819
+ return;
4820
+ }
4821
+
4822
+ container.innerHTML = proxyServers.map(server => {
4823
+ const stateColor = server.state === 'connected' ? 'var(--green)' :
4824
+ server.state === 'connecting' ? 'var(--amber)' : 'var(--red)';
4825
+ const stateLabel = server.state || 'disconnected';
4826
+ const tierLabel = 'Tier ' + server.default_tier;
4827
+
4828
+ return \`
4829
+ <div class="proxy-server-card" data-server="\${esc(server.name)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border);">
4830
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
4831
+ <div style="display: flex; align-items: center; gap: 8px;">
4832
+ <span style="color: \${stateColor}; font-size: 10px;">\\u25CF</span>
4833
+ <span style="font-weight: 600; font-size: 14px;">\${esc(server.name)}</span>
4834
+ <span style="font-size: 11px; color: var(--text-secondary); background: var(--bg); padding: 2px 6px; border-radius: 3px;">\${esc(server.transport_type)}</span>
4835
+ </div>
4836
+ <div style="display: flex; align-items: center; gap: 8px;">
4837
+ <span style="font-size: 11px; color: var(--text-secondary);">\${server.tool_count} tools</span>
4838
+ <span style="font-size: 11px; color: var(--blue); background: rgba(88,166,255,0.1); padding: 2px 6px; border-radius: 3px;">\${tierLabel}</span>
4839
+ <button class="proxy-remove-btn" data-server="\${esc(server.name)}" style="background: none; border: none; color: var(--red); cursor: pointer; font-size: 14px; padding: 2px 6px;" title="Remove server">\\u00D7</button>
4840
+ </div>
4841
+ </div>
4842
+ <div style="font-size: 11px; color: var(--text-secondary);">
4843
+ Status: <span style="color: \${stateColor}">\${esc(stateLabel)}</span>
4844
+ \${server.error ? '<span style="color: var(--red);"> \u2014 ' + esc(server.error) + '</span>' : ''}
4845
+ </div>
4846
+ <div class="proxy-tools-expand" style="margin-top: 8px; display: none;">
4847
+ <div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">Discovered Tools:</div>
4848
+ <div class="proxy-tools-list" style="font-size: 11px; font-family: monospace; color: var(--text-primary); max-height: 150px; overflow-y: auto;"></div>
4849
+ </div>
4850
+ </div>
4851
+ \`;
4852
+ }).join('');
4853
+
4854
+ // Attach remove handlers
4855
+ container.querySelectorAll('.proxy-remove-btn').forEach(btn => {
4856
+ btn.addEventListener('click', (e) => {
4857
+ const serverName = e.target.dataset.server;
4858
+ removeProxyServer(serverName);
4859
+ });
4860
+ });
4861
+
4862
+ // Attach expand/collapse on card click
4863
+ container.querySelectorAll('.proxy-server-card').forEach(card => {
4864
+ card.style.cursor = 'pointer';
4865
+ card.addEventListener('click', (e) => {
4866
+ if (e.target.classList.contains('proxy-remove-btn')) return;
4867
+ const expand = card.querySelector('.proxy-tools-expand');
4868
+ if (expand) {
4869
+ expand.style.display = expand.style.display === 'none' ? 'block' : 'none';
4870
+ }
4871
+ });
4872
+ });
4873
+ }
4874
+
4875
+ function updateProxyServerStatus(serverName, state, toolCount, error) {
4876
+ const server = proxyServers.find(s => s.name === serverName);
4877
+ if (server) {
4878
+ server.state = state;
4879
+ server.tool_count = toolCount;
4880
+ server.error = error;
4881
+ renderProxyServers();
4882
+ }
4883
+ }
4884
+
4885
+ async function addProxyServer(serverConfig) {
4886
+ const current = [...proxyServers];
4887
+ // Check for duplicate
4888
+ if (current.find(s => s.name === serverConfig.name)) {
4889
+ alert('A server with that name already exists');
4890
+ return;
4891
+ }
4892
+ current.push(serverConfig);
4893
+
4894
+ try {
4895
+ const resp = await fetch(API_BASE + '/api/proxy/servers', {
4896
+ method: 'POST',
4897
+ headers: {
4898
+ 'Authorization': 'Bearer ' + AUTH_TOKEN,
4899
+ 'Content-Type': 'application/json',
4900
+ },
4901
+ body: JSON.stringify({ upstream_servers: current }),
4902
+ });
4903
+ if (!resp.ok) {
4904
+ const err = await resp.json();
4905
+ alert('Failed to add server: ' + (err.error || 'Unknown error'));
4906
+ return;
4907
+ }
4908
+ await loadProxyServers();
4909
+ } catch (err) {
4910
+ alert('Failed to add server: ' + err.message);
4911
+ }
4912
+ }
4913
+
4914
+ async function removeProxyServer(serverName) {
4915
+ if (!confirm('Remove upstream server "' + serverName + '"?')) return;
4916
+
4917
+ const updated = proxyServers.filter(s => s.name !== serverName);
4918
+
4919
+ try {
4920
+ const resp = await fetch(API_BASE + '/api/proxy/servers', {
4921
+ method: 'POST',
4922
+ headers: {
4923
+ 'Authorization': 'Bearer ' + AUTH_TOKEN,
4924
+ 'Content-Type': 'application/json',
4925
+ },
4926
+ body: JSON.stringify({ upstream_servers: updated }),
4927
+ });
4928
+ if (!resp.ok) {
4929
+ const err = await resp.json();
4930
+ alert('Failed to remove server: ' + (err.error || 'Unknown error'));
4931
+ return;
4932
+ }
4933
+ await loadProxyServers();
4934
+ } catch (err) {
4935
+ alert('Failed to remove server: ' + err.message);
4936
+ }
4937
+ }
4938
+
4939
+ // Add Server form handlers
4940
+ (function setupProxyForm() {
4941
+ const addBtn = document.getElementById('add-proxy-server-btn');
4942
+ const form = document.getElementById('add-server-form');
4943
+ const saveBtn = document.getElementById('save-server-btn');
4944
+ const cancelBtn = document.getElementById('cancel-server-btn');
4945
+ const transportSelect = document.getElementById('new-server-transport');
4946
+ const stdioFields = document.getElementById('stdio-fields');
4947
+ const sseFields = document.getElementById('sse-fields');
4948
+
4949
+ if (!addBtn || !form) return;
4950
+
4951
+ addBtn.addEventListener('click', () => {
4952
+ form.style.display = form.style.display === 'none' ? 'block' : 'none';
4953
+ });
4954
+
4955
+ cancelBtn.addEventListener('click', () => {
4956
+ form.style.display = 'none';
4957
+ });
4958
+
4959
+ transportSelect.addEventListener('change', () => {
4960
+ if (transportSelect.value === 'stdio') {
4961
+ stdioFields.style.display = 'block';
4962
+ sseFields.style.display = 'none';
4963
+ } else {
4964
+ stdioFields.style.display = 'none';
4965
+ sseFields.style.display = 'block';
4966
+ }
4967
+ });
4968
+
4969
+ saveBtn.addEventListener('click', () => {
4970
+ const name = document.getElementById('new-server-name').value.trim();
4971
+ const type = transportSelect.value;
4972
+ const tier = parseInt(document.getElementById('new-server-tier').value, 10);
4973
+
4974
+ if (!name) { alert('Server name is required'); return; }
4975
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) { alert('Name must contain only letters, numbers, hyphens, and underscores'); return; }
4976
+
4977
+ const transport = { type };
4978
+ if (type === 'stdio') {
4979
+ const command = document.getElementById('new-server-command').value.trim();
4980
+ if (!command) { alert('Command is required for stdio transport'); return; }
4981
+ transport.command = command;
4982
+ const argsStr = document.getElementById('new-server-args').value.trim();
4983
+ if (argsStr) {
4984
+ transport.args = argsStr.split(',').map(s => s.trim()).filter(Boolean);
4985
+ }
4986
+ } else {
4987
+ const url = document.getElementById('new-server-url').value.trim();
4988
+ if (!url) { alert('URL is required for SSE transport'); return; }
4989
+ transport.url = url;
4990
+ }
4991
+
4992
+ addProxyServer({
4993
+ name,
4994
+ transport,
4995
+ enabled: true,
4996
+ default_tier: tier,
4997
+ });
4998
+
4999
+ // Reset form
5000
+ form.style.display = 'none';
5001
+ document.getElementById('new-server-name').value = '';
5002
+ document.getElementById('new-server-command').value = '';
5003
+ document.getElementById('new-server-args').value = '';
5004
+ document.getElementById('new-server-url').value = '';
5005
+ });
5006
+ })();
5007
+
5008
+ // Initialize
5009
+ async function initialize() {
5010
+ if (!AUTH_TOKEN) {
5011
+ redirectToLogin();
5012
+ return;
5013
+ }
5014
+
5015
+ // Initial data fetch
5016
+ await Promise.all([
5017
+ updateSovereignty(),
5018
+ updateIdentity(),
5019
+ updateHandshakes(),
5020
+ updateSHR(),
5021
+ updateStatus(),
5022
+ updateSovereigntyProfile(),
5023
+ loadProxyServers(),
5024
+ ]);
5025
+
5026
+ // Setup SSE for real-time updates
5027
+ setupSSE();
5028
+
5029
+ // Refresh status periodically
5030
+ setInterval(updateStatus, 30000);
5031
+ }
5032
+
5033
+ // Start
5034
+ initialize();
5035
+ </script>
5036
+ </body>
5037
+ </html>`;
5038
+ }
5039
+ var init_dashboard_html = __esm({
5040
+ "src/principal-policy/dashboard-html.ts"() {
5041
+ }
5042
+ });
4279
5043
 
4280
5044
  // src/system-prompt-generator.ts
4281
5045
  function generateSystemPrompt(profile) {
4282
5046
  const activeFeatures = [];
4283
5047
  const inactiveFeatures = [];
5048
+ const activeKeys = [];
4284
5049
  const featureKeys = [
4285
5050
  "audit_logging",
4286
5051
  "injection_detection",
@@ -4292,6 +5057,7 @@ function generateSystemPrompt(profile) {
4292
5057
  const featureConfig = profile.features[key];
4293
5058
  const info = FEATURE_INFO[key];
4294
5059
  if (featureConfig.enabled) {
5060
+ activeKeys.push(key);
4295
5061
  let desc = `- ${info.name}: ${info.activeDescription}`;
4296
5062
  if (key === "injection_detection" && "sensitivity" in featureConfig && featureConfig.sensitivity) {
4297
5063
  desc += ` Sensitivity: ${featureConfig.sensitivity}.`;
@@ -4304,14 +5070,25 @@ function generateSystemPrompt(profile) {
4304
5070
  inactiveFeatures.push(info.disabledDescription);
4305
5071
  }
4306
5072
  }
4307
- const lines = [
4308
- "You are protected by Sanctuary sovereignty infrastructure. The following protections are active:",
4309
- ""
4310
- ];
5073
+ const lines = [];
5074
+ if (activeKeys.length > 0) {
5075
+ lines.push("QUICK START:");
5076
+ const quickStartItems = buildQuickStart(activeKeys);
5077
+ for (const item of quickStartItems) {
5078
+ lines.push(` ${item}`);
5079
+ }
5080
+ lines.push("");
5081
+ }
5082
+ lines.push(
5083
+ "You are protected by Sanctuary sovereignty infrastructure. The following protections are active:"
5084
+ );
5085
+ lines.push("");
4311
5086
  if (activeFeatures.length > 0) {
4312
5087
  lines.push(...activeFeatures);
4313
5088
  } else {
4314
- lines.push("- No features are currently enabled. Contact your operator to configure protections.");
5089
+ lines.push(
5090
+ "- No features are currently enabled. Contact your operator to configure protections."
5091
+ );
4315
5092
  }
4316
5093
  if (inactiveFeatures.length > 0) {
4317
5094
  lines.push("");
@@ -4321,36 +5098,82 @@ function generateSystemPrompt(profile) {
4321
5098
  }
4322
5099
  return lines.join("\n");
4323
5100
  }
5101
+ function buildQuickStart(activeKeys) {
5102
+ const items = [];
5103
+ if (activeKeys.includes("context_gating")) {
5104
+ items.push(
5105
+ "1. ALWAYS call sanctuary/context_gate_filter before sending context to external APIs."
5106
+ );
5107
+ }
5108
+ if (activeKeys.includes("zk_proofs")) {
5109
+ items.push(
5110
+ `${items.length + 1}. Use sanctuary/zk_commit to prove claims without revealing underlying data.`
5111
+ );
5112
+ }
5113
+ if (activeKeys.includes("approval_gate")) {
5114
+ items.push(
5115
+ `${items.length + 1}. High-risk operations will be held for human approval \u2014 expect async responses.`
5116
+ );
5117
+ }
5118
+ if (items.length === 0) {
5119
+ if (activeKeys.includes("audit_logging")) {
5120
+ items.push("1. All tool calls are automatically logged to an encrypted audit trail.");
5121
+ }
5122
+ if (activeKeys.includes("injection_detection")) {
5123
+ items.push(
5124
+ `${items.length + 1}. Tool arguments are scanned for injection \u2014 blocked calls should not be retried.`
5125
+ );
5126
+ }
5127
+ }
5128
+ return items;
5129
+ }
4324
5130
  var FEATURE_INFO;
4325
5131
  var init_system_prompt_generator = __esm({
4326
5132
  "src/system-prompt-generator.ts"() {
4327
5133
  FEATURE_INFO = {
4328
5134
  audit_logging: {
4329
5135
  name: "Audit Logging",
4330
- activeDescription: "All your tool calls are logged to an encrypted audit trail. No action needed \u2014 this is automatic.",
4331
- disabledDescription: "audit logging (sanctuary/monitor_audit_log)"
5136
+ activeDescription: "All your tool calls are logged to an encrypted audit trail. No action needed \u2014 this is automatic. You can query the log with sanctuary/monitor_audit_log if you need to review past activity.",
5137
+ toolNames: ["sanctuary/monitor_audit_log"],
5138
+ disabledDescription: "audit logging (sanctuary/monitor_audit_log)",
5139
+ usageExample: "Automatic \u2014 every tool call you make is recorded. No explicit action required."
4332
5140
  },
4333
5141
  injection_detection: {
4334
5142
  name: "Injection Detection",
4335
- activeDescription: "Your tool call arguments are scanned for prompt injection attempts. No action needed \u2014 this is automatic.",
4336
- disabledDescription: "injection detection"
5143
+ activeDescription: "Your tool call arguments are scanned for prompt injection attempts. This is automatic \u2014 no action needed. If injection is detected in your input, the call will be blocked and you will receive an error. Do not retry blocked calls with the same input.",
5144
+ disabledDescription: "injection detection",
5145
+ usageExample: "Automatic \u2014 if a tool call is blocked with an injection alert, do not retry with the same arguments."
4337
5146
  },
4338
5147
  context_gating: {
4339
5148
  name: "Context Gating",
4340
- activeDescription: "Before making outbound calls to remote providers, filter your context through sanctuary/context_gate_filter to ensure minimum-necessary disclosure.",
4341
- toolNames: ["sanctuary/context_gate_filter", "sanctuary/context_gate_set_policy"],
4342
- disabledDescription: "context gating (sanctuary/context_gate_filter)"
5149
+ activeDescription: "Before sending context to any external API (LLM inference, tool APIs, logging services), call sanctuary/context_gate_filter to strip sensitive fields. Use sanctuary/context_gate_set_policy to define filtering rules, or sanctuary/context_gate_apply_template for presets.",
5150
+ toolNames: [
5151
+ "sanctuary/context_gate_filter",
5152
+ "sanctuary/context_gate_set_policy",
5153
+ "sanctuary/context_gate_apply_template",
5154
+ "sanctuary/context_gate_recommend",
5155
+ "sanctuary/context_gate_list_policies"
5156
+ ],
5157
+ disabledDescription: "context gating (sanctuary/context_gate_filter)",
5158
+ usageExample: "Before calling an external API, run: sanctuary/context_gate_filter with your context object and policy_id to get a filtered version."
4343
5159
  },
4344
5160
  approval_gate: {
4345
5161
  name: "Approval Gates",
4346
- activeDescription: "High-risk operations require human approval before execution. Tier 1 operations always require approval; Tier 2 operations trigger approval on anomaly detection.",
4347
- disabledDescription: "approval gates"
5162
+ activeDescription: "High-risk operations require human approval before execution. Tier 1 operations (export, import, key rotation, deletion) always require approval. Tier 2 operations trigger approval when anomalous behavior is detected. When an operation is held for approval, you will receive an async response \u2014 wait for the human decision before proceeding.",
5163
+ disabledDescription: "approval gates",
5164
+ usageExample: "When you call a Tier 1 operation (e.g., state_export), expect an async hold. The human operator will approve or deny via the dashboard."
4348
5165
  },
4349
5166
  zk_proofs: {
4350
5167
  name: "Zero-Knowledge Proofs",
4351
- activeDescription: "You can prove claims about your data without revealing the underlying values. Use sanctuary/zk_commit to create commitments, sanctuary/zk_prove for proofs of knowledge, and sanctuary/zk_range_prove for range proofs.",
4352
- toolNames: ["sanctuary/zk_commit", "sanctuary/zk_prove", "sanctuary/zk_range_prove"],
4353
- disabledDescription: "zero-knowledge proofs (sanctuary/zk_commit, sanctuary/zk_prove)"
5168
+ activeDescription: "You can prove claims about your data without revealing the underlying values. Use sanctuary/zk_commit to create a Pedersen commitment, sanctuary/zk_prove (Schnorr proof) to prove you know a committed value, and sanctuary/zk_range_prove to prove a value falls within a range \u2014 all without disclosing the actual data. For simpler SHA-256 commitments, use sanctuary/proof_commitment.",
5169
+ toolNames: [
5170
+ "sanctuary/zk_commit",
5171
+ "sanctuary/zk_prove",
5172
+ "sanctuary/zk_range_prove",
5173
+ "sanctuary/proof_commitment"
5174
+ ],
5175
+ disabledDescription: "zero-knowledge proofs (sanctuary/zk_commit, sanctuary/zk_prove)",
5176
+ usageExample: "To prove a claim without revealing data: first sanctuary/zk_commit to commit, then sanctuary/zk_prove or sanctuary/zk_range_prove to generate a verifiable proof."
4354
5177
  }
4355
5178
  };
4356
5179
  }
@@ -4382,6 +5205,7 @@ var init_dashboard = __esm({
4382
5205
  shrOpts = null;
4383
5206
  _sanctuaryConfig = null;
4384
5207
  profileStore = null;
5208
+ clientManager = null;
4385
5209
  dashboardHTML;
4386
5210
  loginHTML;
4387
5211
  authToken;
@@ -4403,8 +5227,7 @@ var init_dashboard = __esm({
4403
5227
  this.sessionTTLMs = isLocalhost ? SESSION_TTL_LOCAL_MS : SESSION_TTL_REMOTE_MS;
4404
5228
  this.dashboardHTML = generateDashboardHTML({
4405
5229
  timeoutSeconds: config.timeout_seconds,
4406
- serverVersion: SANCTUARY_VERSION,
4407
- authToken: this.authToken
5230
+ serverVersion: SANCTUARY_VERSION
4408
5231
  });
4409
5232
  this.loginHTML = generateLoginHTML({ serverVersion: SANCTUARY_VERSION });
4410
5233
  this.sessionCleanupTimer = setInterval(() => this.cleanupSessions(), 6e4);
@@ -4422,6 +5245,7 @@ var init_dashboard = __esm({
4422
5245
  if (deps.shrOpts) this.shrOpts = deps.shrOpts;
4423
5246
  if (deps.sanctuaryConfig) this._sanctuaryConfig = deps.sanctuaryConfig;
4424
5247
  if (deps.profileStore) this.profileStore = deps.profileStore;
5248
+ if (deps.clientManager) this.clientManager = deps.clientManager;
4425
5249
  }
4426
5250
  /**
4427
5251
  * Mark this dashboard as running in standalone mode.
@@ -4471,7 +5295,24 @@ var init_dashboard = __esm({
4471
5295
  }
4472
5296
  resolve();
4473
5297
  });
4474
- this.httpServer.on("error", reject);
5298
+ this.httpServer.on("error", (err) => {
5299
+ if (err.code === "EADDRINUSE") {
5300
+ const port = this.config.port;
5301
+ process.stderr.write(
5302
+ `
5303
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
5304
+ \u2551 Port ${port} is already in use. \u2551
5305
+ \u2551 \u2551
5306
+ \u2551 Another Sanctuary Dashboard may still be running. \u2551
5307
+ \u2551 To fix: lsof -ti:${port} | xargs kill \u2551
5308
+ \u2551 Then restart the dashboard. \u2551
5309
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
5310
+
5311
+ `
5312
+ );
5313
+ }
5314
+ reject(err);
5315
+ });
4475
5316
  });
4476
5317
  }
4477
5318
  /**
@@ -4751,7 +5592,7 @@ var init_dashboard = __esm({
4751
5592
  if (!this.checkAuth(req, url, res)) return;
4752
5593
  if (!this.checkRateLimit(req, res, "general")) return;
4753
5594
  try {
4754
- if (method === "GET" && url.pathname === "/") {
5595
+ if (method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) {
4755
5596
  this.serveDashboard(res);
4756
5597
  } else if (method === "GET" && url.pathname === "/events") {
4757
5598
  this.handleSSE(req, res);
@@ -4773,6 +5614,10 @@ var init_dashboard = __esm({
4773
5614
  this.handleSovereigntyProfileGet(res);
4774
5615
  } else if (method === "POST" && url.pathname === "/api/sovereignty-profile") {
4775
5616
  this.handleSovereigntyProfileUpdate(req, res);
5617
+ } else if (method === "GET" && url.pathname === "/api/proxy/servers") {
5618
+ this.handleProxyServers(res);
5619
+ } else if (method === "POST" && url.pathname === "/api/proxy/servers") {
5620
+ this.handleProxyServersUpdate(req, res);
4776
5621
  } else if (method === "POST" && url.pathname.startsWith("/api/approve/")) {
4777
5622
  if (!this.checkRateLimit(req, res, "decisions")) return;
4778
5623
  const id = url.pathname.slice("/api/approve/".length);
@@ -5119,38 +5964,117 @@ data: ${JSON.stringify(initData)}
5119
5964
  }
5120
5965
  });
5121
5966
  }
5122
- // ── SSE Broadcasting ────────────────────────────────────────────────
5123
- broadcastSSE(event, data) {
5124
- const message = `event: ${event}
5125
- data: ${JSON.stringify(data)}
5126
-
5127
- `;
5128
- for (const client of this.sseClients) {
5129
- try {
5130
- client.write(message);
5131
- } catch {
5132
- this.sseClients.delete(client);
5133
- }
5134
- }
5135
- }
5967
+ // ── Proxy Server Handlers ───────────────────────────────────────────
5136
5968
  /**
5137
- * Broadcast an audit entry to connected dashboards.
5138
- * Called externally when audit events happen.
5969
+ * GET /api/proxy/servers list upstream proxy servers and their status.
5139
5970
  */
5140
- broadcastAuditEntry(entry) {
5141
- this.broadcastSSE("audit-entry", entry);
5971
+ handleProxyServers(res) {
5972
+ const profile = this.profileStore?.get();
5973
+ const upstreamServers = profile?.upstream_servers ?? [];
5974
+ const clientStatus = this.clientManager?.getStatus() ?? [];
5975
+ const servers = upstreamServers.map((server) => {
5976
+ const status = clientStatus.find((s) => s.name === server.name);
5977
+ return {
5978
+ name: server.name,
5979
+ transport_type: server.transport.type,
5980
+ enabled: server.enabled,
5981
+ default_tier: server.default_tier,
5982
+ state: status?.state ?? "disconnected",
5983
+ tool_count: status?.tool_count ?? 0,
5984
+ error: status?.error,
5985
+ tool_overrides: server.tool_overrides ?? {}
5986
+ };
5987
+ });
5988
+ res.writeHead(200, { "Content-Type": "application/json" });
5989
+ res.end(JSON.stringify({ servers }));
5142
5990
  }
5143
5991
  /**
5144
- * Broadcast a baseline update to connected dashboards.
5145
- * Called externally after baseline changes.
5992
+ * POST /api/proxy/servers update upstream server configuration.
5993
+ * This is a dashboard action (human-initiated), so it's allowed with audit logging
5994
+ * rather than requiring Tier 1 approval.
5146
5995
  */
5147
- broadcastBaselineUpdate() {
5148
- if (this.baseline) {
5149
- this.broadcastSSE("baseline-update", this.baseline.getProfile());
5996
+ handleProxyServersUpdate(req, res) {
5997
+ if (!this.profileStore) {
5998
+ res.writeHead(500, { "Content-Type": "application/json" });
5999
+ res.end(JSON.stringify({ error: "Profile store not available" }));
6000
+ return;
5150
6001
  }
5151
- }
5152
- /**
5153
- * Broadcast a tool call event to connected dashboards.
6002
+ let body = "";
6003
+ let destroyed = false;
6004
+ req.on("data", (chunk) => {
6005
+ body += chunk.toString();
6006
+ if (body.length > 16384) {
6007
+ destroyed = true;
6008
+ res.writeHead(413, { "Content-Type": "application/json" });
6009
+ res.end(JSON.stringify({ error: "Request body too large" }));
6010
+ req.destroy();
6011
+ }
6012
+ });
6013
+ req.on("end", async () => {
6014
+ if (destroyed) return;
6015
+ try {
6016
+ const { upstream_servers } = JSON.parse(body);
6017
+ const updated = await this.profileStore.update({ upstream_servers });
6018
+ if (this.auditLog) {
6019
+ this.auditLog.append("l2", "proxy_servers_update_dashboard", "dashboard", {
6020
+ server_count: upstream_servers.length,
6021
+ servers: upstream_servers.map((s) => ({
6022
+ name: s.name,
6023
+ type: s.transport.type,
6024
+ enabled: s.enabled,
6025
+ tier: s.default_tier
6026
+ }))
6027
+ });
6028
+ }
6029
+ if (this.clientManager && updated.upstream_servers) {
6030
+ this.clientManager.configure(updated.upstream_servers).catch(() => {
6031
+ });
6032
+ }
6033
+ this.broadcastSSE("proxy-servers-update", {
6034
+ servers: updated.upstream_servers ?? [],
6035
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6036
+ });
6037
+ res.writeHead(200, { "Content-Type": "application/json" });
6038
+ res.end(JSON.stringify({ upstream_servers: updated.upstream_servers ?? [] }));
6039
+ } catch (err) {
6040
+ const message = err instanceof Error ? err.message : "Invalid request";
6041
+ res.writeHead(400, { "Content-Type": "application/json" });
6042
+ res.end(JSON.stringify({ error: message }));
6043
+ }
6044
+ });
6045
+ }
6046
+ // ── SSE Broadcasting ────────────────────────────────────────────────
6047
+ broadcastSSE(event, data) {
6048
+ const message = `event: ${event}
6049
+ data: ${JSON.stringify(data)}
6050
+
6051
+ `;
6052
+ for (const client of this.sseClients) {
6053
+ try {
6054
+ client.write(message);
6055
+ } catch {
6056
+ this.sseClients.delete(client);
6057
+ }
6058
+ }
6059
+ }
6060
+ /**
6061
+ * Broadcast an audit entry to connected dashboards.
6062
+ * Called externally when audit events happen.
6063
+ */
6064
+ broadcastAuditEntry(entry) {
6065
+ this.broadcastSSE("audit-entry", entry);
6066
+ }
6067
+ /**
6068
+ * Broadcast a baseline update to connected dashboards.
6069
+ * Called externally after baseline changes.
6070
+ */
6071
+ broadcastBaselineUpdate() {
6072
+ if (this.baseline) {
6073
+ this.broadcastSSE("baseline-update", this.baseline.getProfile());
6074
+ }
6075
+ }
6076
+ /**
6077
+ * Broadcast a tool call event to connected dashboards.
5154
6078
  * Called from the gate or router when a tool is invoked.
5155
6079
  */
5156
6080
  broadcastToolCall(data) {
@@ -5229,7 +6153,8 @@ function createDefaultProfile() {
5229
6153
  audit_logging: { enabled: true },
5230
6154
  injection_detection: { enabled: true },
5231
6155
  context_gating: { enabled: false },
5232
- approval_gate: { enabled: false },
6156
+ approval_gate: { enabled: true },
6157
+ // SEC-057: always enabled — core enforcement
5233
6158
  zk_proofs: { enabled: false }
5234
6159
  },
5235
6160
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -5289,6 +6214,9 @@ var init_sovereignty_profile = __esm({
5289
6214
  if (!this.profile) {
5290
6215
  await this.load();
5291
6216
  }
6217
+ if (updates.approval_gate && updates.approval_gate.enabled === false) {
6218
+ throw new Error("approval_gate cannot be disabled \u2014 it is a core enforcement feature");
6219
+ }
5292
6220
  const features = this.profile.features;
5293
6221
  if (updates.audit_logging !== void 0) {
5294
6222
  if (updates.audit_logging.enabled !== void 0) {
@@ -5343,6 +6271,48 @@ var init_sovereignty_profile = __esm({
5343
6271
  features.zk_proofs.enabled = updates.zk_proofs.enabled;
5344
6272
  }
5345
6273
  }
6274
+ if (updates.upstream_servers !== void 0) {
6275
+ if (!Array.isArray(updates.upstream_servers)) {
6276
+ throw new Error("upstream_servers must be an array");
6277
+ }
6278
+ for (const server of updates.upstream_servers) {
6279
+ if (!server.name || typeof server.name !== "string") {
6280
+ throw new Error("Each upstream server must have a name");
6281
+ }
6282
+ if (server.name.length > 128) {
6283
+ throw new Error("Upstream server name must be 128 characters or fewer");
6284
+ }
6285
+ if (!/^[a-zA-Z0-9_-]+$/.test(server.name)) {
6286
+ throw new Error("Upstream server name must contain only alphanumeric characters, hyphens, and underscores");
6287
+ }
6288
+ if (!server.transport || typeof server.transport !== "object") {
6289
+ throw new Error("Each upstream server must have a transport configuration");
6290
+ }
6291
+ if (server.transport.type !== "stdio" && server.transport.type !== "sse") {
6292
+ throw new Error("Transport type must be 'stdio' or 'sse'");
6293
+ }
6294
+ if (server.transport.type === "stdio" && !server.transport.command) {
6295
+ throw new Error("stdio transport requires a command");
6296
+ }
6297
+ if (server.transport.type === "sse" && !server.transport.url) {
6298
+ throw new Error("sse transport requires a url");
6299
+ }
6300
+ if (typeof server.enabled !== "boolean") {
6301
+ throw new Error("Each upstream server must have enabled as a boolean");
6302
+ }
6303
+ if (![1, 2, 3].includes(server.default_tier)) {
6304
+ throw new Error("default_tier must be 1, 2, or 3");
6305
+ }
6306
+ if (server.tool_overrides) {
6307
+ for (const [, override] of Object.entries(server.tool_overrides)) {
6308
+ if (![1, 2, 3].includes(override.tier)) {
6309
+ throw new Error("tool_overrides tier must be 1, 2, or 3");
6310
+ }
6311
+ }
6312
+ }
6313
+ }
6314
+ this.profile.upstream_servers = updates.upstream_servers;
6315
+ }
5346
6316
  this.profile.updated_at = (/* @__PURE__ */ new Date()).toISOString();
5347
6317
  await this.persist();
5348
6318
  return this.profile;
@@ -8145,6 +9115,70 @@ var TOOL_INVOCATION_PATTERNS = [
8145
9115
  ];
8146
9116
  var URL_PATTERN = /https?:\/\/[^\s"'<>]+/i;
8147
9117
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
9118
+ var INVISIBLE_CHARS = [
9119
+ "\u200B",
9120
+ // Zero-width space
9121
+ "\u200C",
9122
+ // Zero-width non-joiner
9123
+ "\u200D",
9124
+ // Zero-width joiner
9125
+ "\uFEFF",
9126
+ // Zero-width no-break space (BOM)
9127
+ "\xAD",
9128
+ // Soft hyphen
9129
+ "\u200E",
9130
+ // Left-to-right mark
9131
+ "\u200F",
9132
+ // Right-to-left mark
9133
+ "\u2060",
9134
+ // Word joiner
9135
+ "\u2061",
9136
+ // Function application
9137
+ "\u2062",
9138
+ // Invisible times
9139
+ "\u2063",
9140
+ // Invisible separator
9141
+ "\u2064",
9142
+ // Invisible plus
9143
+ "\u180E",
9144
+ // Mongolian vowel separator
9145
+ "\u034F",
9146
+ // Combining grapheme joiner
9147
+ "\u061C",
9148
+ // Arabic letter mark
9149
+ "\u115F",
9150
+ // Hangul choseong filler
9151
+ "\u1160",
9152
+ // Hangul jungseong filler
9153
+ "\u17B4",
9154
+ // Khmer vowel inherent AQ
9155
+ "\u17B5",
9156
+ // Khmer vowel inherent AA
9157
+ "\u3164",
9158
+ // Hangul filler
9159
+ "\uFFA0",
9160
+ // Halfwidth hangul filler
9161
+ "\u202A",
9162
+ // Left-to-Right Embedding (LRE)
9163
+ "\u202B",
9164
+ // Right-to-Left Embedding (RLE)
9165
+ "\u202C",
9166
+ // Pop Directional Formatting (PDF)
9167
+ "\u202D",
9168
+ // Left-to-Right Override (LRO)
9169
+ "\u202E",
9170
+ // Right-to-Left Override (RLO)
9171
+ "\u2066",
9172
+ // Left-to-Right Isolate (LRI)
9173
+ "\u2067",
9174
+ // Right-to-Left Isolate (RLI)
9175
+ "\u2068",
9176
+ // First Strong Isolate (FSI)
9177
+ "\u2069"
9178
+ // Pop Directional Isolate (PDI)
9179
+ ];
9180
+ var VARIATION_SELECTOR_RANGE_START = 65024;
9181
+ var VARIATION_SELECTOR_RANGE_END = 65039;
8148
9182
  var ZERO_WIDTH_CHARS = [
8149
9183
  "\u200B",
8150
9184
  // Zero-width space
@@ -8155,6 +9189,66 @@ var ZERO_WIDTH_CHARS = [
8155
9189
  "\uFEFF"
8156
9190
  // Zero-width no-break space
8157
9191
  ];
9192
+ var BASE64_STANDARD_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
9193
+ var BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
9194
+ var BASE64_BLOCK_PATTERN = /[A-Za-z0-9+/]{20,}={0,2}/g;
9195
+ var HEX_ENCODED_PATTERN = /(?:0x)?[0-9a-fA-F]{20,}/g;
9196
+ var HTML_ENTITY_PATTERN = /&#(?:x[0-9a-fA-F]{2,4}|[0-9]{2,5});/g;
9197
+ var URL_ENCODED_PATTERN = /(?:%[0-9a-fA-F]{2}){4,}/g;
9198
+ var SECRET_PATTERNS = [
9199
+ { pattern: /sk-[a-zA-Z0-9]{20,}/, name: "openai_api_key" },
9200
+ { pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/, name: "anthropic_api_key" },
9201
+ { pattern: /ghp_[a-zA-Z0-9]{36,}/, name: "github_pat" },
9202
+ { pattern: /gho_[a-zA-Z0-9]{36,}/, name: "github_oauth" },
9203
+ { pattern: /ghs_[a-zA-Z0-9]{36,}/, name: "github_app" },
9204
+ { pattern: /github_pat_[a-zA-Z0-9_]{22,}/, name: "github_fine_grained_pat" },
9205
+ { pattern: /AKIA[0-9A-Z]{16}/, name: "aws_access_key" },
9206
+ { pattern: /xoxb-[0-9]{10,}-[a-zA-Z0-9-]+/, name: "slack_bot_token" },
9207
+ { pattern: /xoxp-[0-9]{10,}-[a-zA-Z0-9-]+/, name: "slack_user_token" },
9208
+ { pattern: /xapp-[0-9]-[A-Z0-9]+-[0-9]+-[a-z0-9]+/, name: "slack_app_token" },
9209
+ { pattern: /(?:Bearer|bearer)\s+[a-zA-Z0-9._~+/=-]{20,}/, name: "bearer_token" },
9210
+ { pattern: /glpat-[a-zA-Z0-9_-]{20,}/, name: "gitlab_pat" },
9211
+ { pattern: /npm_[a-zA-Z0-9]{36,}/, name: "npm_token" },
9212
+ { pattern: /pypi-[a-zA-Z0-9_-]{20,}/, name: "pypi_token" },
9213
+ { pattern: /AIza[a-zA-Z0-9_-]{35}/, name: "google_api_key" },
9214
+ { pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/, name: "sendgrid_api_key" },
9215
+ { pattern: /sq0[a-z]{3}-[a-zA-Z0-9_-]{22,}/, name: "square_api_key" },
9216
+ { pattern: /sk_live_[a-zA-Z0-9]{24,}/, name: "stripe_secret_key" },
9217
+ { pattern: /rk_live_[a-zA-Z0-9]{24,}/, name: "stripe_restricted_key" },
9218
+ { pattern: /(?:-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----)/, name: "private_key_pem" }
9219
+ ];
9220
+ var MARKDOWN_IMAGE_EXFIL_PATTERN = /!\[[^\]]*\]\(https?:\/\/[^)]*[?&](?:data|secret|key|token|password|auth|session|cookie|api_key|access_token)=/i;
9221
+ var INTERNAL_PATH_PATTERNS = [
9222
+ /\/home\/[a-zA-Z0-9_.-]+\//,
9223
+ /\/Users\/[a-zA-Z0-9_.-]+\//,
9224
+ /[A-Z]:\\(?:Users|Documents|Program Files)\\/,
9225
+ /~\/\.(?:ssh|aws|config|gnupg|sanctuary)\//,
9226
+ /\/etc\/(?:passwd|shadow|hosts|ssh)/,
9227
+ /\/var\/(?:log|run|lib)\//
9228
+ ];
9229
+ var PRIVATE_NETWORK_PATTERNS = [
9230
+ /(?:^|\s|\/\/)(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3})/,
9231
+ /(?:^|\s|\/\/)(?:172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})/,
9232
+ /(?:^|\s|\/\/)(?:192\.168\.\d{1,3}\.\d{1,3})/,
9233
+ /(?:^|\s|\/\/)localhost(?::\d+)?/,
9234
+ /(?:^|\s|\/\/)127\.0\.0\.1/,
9235
+ /(?:^|\s|\/\/)0\.0\.0\.0/,
9236
+ /(?:^|\s|\/\/)\[?::1\]?/
9237
+ ];
9238
+ var OUTPUT_ROLE_MARKER_PATTERNS = [
9239
+ /\bsystem\s*:/i,
9240
+ /\[INST\]/,
9241
+ /\[\/INST\]/,
9242
+ /<\|im_start\|>/,
9243
+ /<\|im_end\|>/,
9244
+ /<\|system\|>/,
9245
+ /<\|user\|>/,
9246
+ /<\|assistant\|>/,
9247
+ /<<\s*SYS\s*>>/,
9248
+ /<<\s*\/SYS\s*>>/,
9249
+ /\[SYSTEM\]/,
9250
+ /### (?:System|Human|Assistant):/
9251
+ ];
8158
9252
  var InjectionDetector = class {
8159
9253
  config;
8160
9254
  stats = {
@@ -8211,6 +9305,50 @@ var InjectionDetector = class {
8211
9305
  recommendation
8212
9306
  };
8213
9307
  }
9308
+ /**
9309
+ * SEC-035: Scan outbound content for secret leaks, data exfiltration,
9310
+ * internal path exposure, and injection artifact survival.
9311
+ *
9312
+ * @param content The outbound content string to scan
9313
+ * @returns DetectionResult with outbound-specific signal types
9314
+ */
9315
+ scanOutbound(content) {
9316
+ this.stats.total_scans++;
9317
+ if (!this.config.enabled) {
9318
+ return {
9319
+ flagged: false,
9320
+ confidence: 0,
9321
+ signals: [],
9322
+ recommendation: "allow"
9323
+ };
9324
+ }
9325
+ const signals = [];
9326
+ this.detectSecretPatterns(content, signals);
9327
+ this.detectOutboundExfiltration(content, signals);
9328
+ this.detectInternalPathLeaks(content, signals);
9329
+ this.detectPrivateNetworkLeaks(content, signals);
9330
+ this.detectOutputRoleMarkers(content, signals);
9331
+ const flagged = signals.length > 0;
9332
+ if (flagged) {
9333
+ this.stats.total_flags++;
9334
+ }
9335
+ for (const sig of signals) {
9336
+ this.stats.signals_by_type[sig.type] = (this.stats.signals_by_type[sig.type] ?? 0) + 1;
9337
+ }
9338
+ const recommendation = this.computeRecommendation(
9339
+ signals,
9340
+ this.config.sensitivity
9341
+ );
9342
+ if (recommendation === "block") {
9343
+ this.stats.total_blocks++;
9344
+ }
9345
+ return {
9346
+ flagged,
9347
+ confidence: this.computeConfidence(signals),
9348
+ signals,
9349
+ recommendation
9350
+ };
9351
+ }
8214
9352
  /**
8215
9353
  * Recursively scan a value and all nested values.
8216
9354
  */
@@ -8239,14 +9377,40 @@ var InjectionDetector = class {
8239
9377
  return;
8240
9378
  }
8241
9379
  const location = path || "root";
8242
- const normalized = this.normalizeConfusables(value.normalize("NFKC"));
8243
- if (normalized !== value) {
9380
+ const invisibleCount = this.countInvisibleChars(value);
9381
+ if (invisibleCount > 3) {
9382
+ signals.push({
9383
+ type: "unicode_smuggling",
9384
+ pattern: `invisible_chars_count_${invisibleCount}`,
9385
+ location,
9386
+ severity: "high"
9387
+ });
9388
+ }
9389
+ this.detectTokenBudgetAttack(value, location, signals);
9390
+ const stripped = this.stripInvisibleChars(value);
9391
+ const nfkcOnly = stripped.normalize("NFKC");
9392
+ const normalized = this.normalizeConfusables(nfkcOnly);
9393
+ if (nfkcOnly !== stripped) {
9394
+ signals.push({
9395
+ type: "encoding_evasion",
9396
+ pattern: "unicode_normalization_delta",
9397
+ location,
9398
+ severity: "medium"
9399
+ });
9400
+ }
9401
+ if (normalized !== nfkcOnly) {
8244
9402
  signals.push({
8245
9403
  type: "encoding_evasion",
8246
9404
  pattern: "unicode_normalization_delta",
8247
9405
  location,
8248
9406
  severity: "medium"
8249
9407
  });
9408
+ signals.push({
9409
+ type: "homoglyph_attack",
9410
+ pattern: "confusable_substitution",
9411
+ location,
9412
+ severity: "high"
9413
+ });
8250
9414
  }
8251
9415
  for (const pattern of ROLE_OVERRIDE_PATTERNS) {
8252
9416
  if (pattern.test(normalized)) {
@@ -8284,129 +9448,471 @@ var InjectionDetector = class {
8284
9448
  }
8285
9449
  }
8286
9450
  this.detectEncodingEvasion(value, location, signals);
9451
+ this.detectEncodedPayloads(value, location, signals);
8287
9452
  this.detectDataExfiltration(value, location, signals);
8288
9453
  this.detectPromptStuffing(value, location, signals);
8289
9454
  }
9455
+ // ═══════════════════════════════════════════════════════════════════════════
9456
+ // SEC-034: Unicode sanitization helpers
9457
+ // ═══════════════════════════════════════════════════════════════════════════
8290
9458
  /**
8291
- * Detect base64 strings and zero-width character evasion.
9459
+ * Count invisible Unicode characters in a string.
9460
+ * Includes zero-width chars, soft hyphens, directional marks,
9461
+ * variation selectors, and other invisible categories.
8292
9462
  */
8293
- detectEncodingEvasion(value, path, signals) {
8294
- if (value.length > 50 && /^[A-Za-z0-9+/]+={0,2}$/.test(value.trim())) {
8295
- signals.push({
8296
- type: "encoding_evasion",
8297
- pattern: "base64_string",
8298
- location: path || "root",
8299
- severity: "medium"
8300
- });
9463
+ countInvisibleChars(value) {
9464
+ let count = 0;
9465
+ for (const ch of value) {
9466
+ const cp = ch.codePointAt(0);
9467
+ if (cp === void 0) continue;
9468
+ if (INVISIBLE_CHARS.includes(ch)) {
9469
+ count++;
9470
+ continue;
9471
+ }
9472
+ if (cp >= VARIATION_SELECTOR_RANGE_START && cp <= VARIATION_SELECTOR_RANGE_END) {
9473
+ count++;
9474
+ continue;
9475
+ }
9476
+ if (cp >= 917760 && cp <= 917999) {
9477
+ count++;
9478
+ continue;
9479
+ }
9480
+ if (cp >= 917505 && cp <= 917631) {
9481
+ count++;
9482
+ continue;
9483
+ }
9484
+ if (cp >= 65529 && cp <= 65531) {
9485
+ count++;
9486
+ continue;
9487
+ }
8301
9488
  }
8302
- let zeroWidthCount = 0;
8303
- for (const char of ZERO_WIDTH_CHARS) {
8304
- zeroWidthCount += (value.match(new RegExp(char, "g")) || []).length;
9489
+ return count;
9490
+ }
9491
+ /**
9492
+ * Strip invisible characters from a string for clean pattern matching.
9493
+ * Returns a new string with all invisible chars removed.
9494
+ */
9495
+ stripInvisibleChars(value) {
9496
+ const chars = [];
9497
+ for (const ch of value) {
9498
+ const cp = ch.codePointAt(0);
9499
+ if (cp === void 0) continue;
9500
+ if (INVISIBLE_CHARS.includes(ch)) continue;
9501
+ if (cp >= VARIATION_SELECTOR_RANGE_START && cp <= VARIATION_SELECTOR_RANGE_END) continue;
9502
+ if (cp >= 917760 && cp <= 917999) continue;
9503
+ if (cp >= 917505 && cp <= 917631) continue;
9504
+ if (cp >= 65529 && cp <= 65531) continue;
9505
+ chars.push(ch);
9506
+ }
9507
+ return chars.join("");
9508
+ }
9509
+ /**
9510
+ * SEC-034: Token budget attack detection.
9511
+ * Some Unicode sequences expand dramatically during tokenization (e.g., CJK
9512
+ * ideographs, combining characters, emoji sequences). If the estimated token
9513
+ * cost per character is anomalously high, this may be a wallet-drain payload.
9514
+ *
9515
+ * Heuristic: count chars that typically tokenize into multiple tokens.
9516
+ * If the ratio of estimated tokens to char count exceeds 3x, flag it.
9517
+ */
9518
+ detectTokenBudgetAttack(value, path, signals) {
9519
+ if (value.length < 20) return;
9520
+ const MAX_ANALYSIS_LENGTH = 1e6;
9521
+ if (value.length > MAX_ANALYSIS_LENGTH) {
9522
+ value = value.substring(0, MAX_ANALYSIS_LENGTH);
9523
+ }
9524
+ let estimatedTokens = 0;
9525
+ for (const ch of value) {
9526
+ const cp = ch.codePointAt(0);
9527
+ if (cp === void 0) continue;
9528
+ if (cp <= 127) {
9529
+ estimatedTokens += 0.25;
9530
+ } else if (cp <= 2047) {
9531
+ estimatedTokens += 0.5;
9532
+ } else if (cp <= 65535) {
9533
+ estimatedTokens += 1.5;
9534
+ } else {
9535
+ estimatedTokens += 2.5;
9536
+ }
8305
9537
  }
8306
- if (zeroWidthCount > 0) {
8307
- signals.push({
8308
- type: "encoding_evasion",
8309
- pattern: "zero_width_characters",
8310
- location: path || "root",
8311
- severity: "medium"
8312
- });
9538
+ let charCount = 0;
9539
+ for (const _ch of value) {
9540
+ charCount++;
8313
9541
  }
8314
- const hasLatin = /[a-zA-Z]/.test(value);
8315
- const hasCJK = /[\u4E00-\u9FFF\u3040-\u309F\uAC00-\uD7AF]/.test(value);
8316
- const hasArabic = /[\u0600-\u06FF]/.test(value);
8317
- const hasCyrillic = /[\u0400-\u04FF]/.test(value);
8318
- const unicodeCategories = [hasLatin, hasCJK, hasArabic, hasCyrillic].filter(
8319
- (x) => x
8320
- ).length;
8321
- if (unicodeCategories >= 3) {
9542
+ const ratio = estimatedTokens / charCount;
9543
+ if (ratio > 3) {
8322
9544
  signals.push({
8323
- type: "encoding_evasion",
8324
- pattern: "unicode_category_mixing",
8325
- location: path || "root",
9545
+ type: "token_budget_attack",
9546
+ pattern: `token_char_ratio_${ratio.toFixed(2)}`,
9547
+ location: path,
8326
9548
  severity: "medium"
8327
9549
  });
8328
9550
  }
8329
9551
  }
9552
+ // ═══════════════════════════════════════════════════════════════════════════
9553
+ // SEC-034: Encoded payload detection and re-scanning
9554
+ // ═══════════════════════════════════════════════════════════════════════════
8330
9555
  /**
8331
- * Detect URLs and emails in fields that shouldn't have them.
9556
+ * Detect encoded content (base64, hex, HTML entities, URL encoding),
9557
+ * decode it, and re-scan the decoded content through injection patterns.
9558
+ * If the decoded content contains injection patterns, flag as encoding_evasion.
8332
9559
  */
8333
- detectDataExfiltration(value, path, signals) {
8334
- if (this.isUrlSafeField(path)) {
8335
- return;
9560
+ detectEncodedPayloads(value, path, signals) {
9561
+ const decodedParts = [];
9562
+ const base64Matches = value.match(BASE64_BLOCK_PATTERN);
9563
+ if (base64Matches) {
9564
+ for (const match of base64Matches) {
9565
+ const decoded = this.safeBase64Decode(match);
9566
+ if (decoded !== null) {
9567
+ decodedParts.push(decoded);
9568
+ }
9569
+ }
8336
9570
  }
8337
- if (URL_PATTERN.test(value)) {
8338
- signals.push({
8339
- type: "data_exfiltration",
8340
- pattern: "url_in_string",
8341
- location: path || "root",
8342
- severity: "medium"
8343
- });
9571
+ const hexMatches = value.match(HEX_ENCODED_PATTERN);
9572
+ if (hexMatches) {
9573
+ for (const match of hexMatches) {
9574
+ const decoded = this.safeHexDecode(match);
9575
+ if (decoded !== null) {
9576
+ decodedParts.push(decoded);
9577
+ }
9578
+ }
8344
9579
  }
8345
- if (EMAIL_PATTERN.test(value) && !this.isEmailSafeField(path)) {
8346
- signals.push({
8347
- type: "data_exfiltration",
8348
- pattern: "email_in_string",
8349
- location: path || "root",
8350
- severity: "medium"
8351
- });
9580
+ if (HTML_ENTITY_PATTERN.test(value)) {
9581
+ const decoded = this.decodeHtmlEntities(value);
9582
+ if (decoded !== value) {
9583
+ decodedParts.push(decoded);
9584
+ }
8352
9585
  }
8353
- if (value.length > 30 && value.length < 1e4 && !this.isStructuredField(path)) {
8354
- const hasJsonContent = /\{[^}]*"[^"]*"[^}]*\}/.test(value);
8355
- const hasXmlContent = /<[^>]+>[\s\S]*?<\/[^>]+>/.test(value);
8356
- if (hasJsonContent || hasXmlContent) {
9586
+ const urlMatches = value.match(URL_ENCODED_PATTERN);
9587
+ if (urlMatches) {
9588
+ for (const match of urlMatches) {
9589
+ const decoded = this.safeUrlDecode(match);
9590
+ if (decoded !== null && decoded !== match) {
9591
+ decodedParts.push(decoded);
9592
+ }
9593
+ }
9594
+ }
9595
+ for (const decoded of decodedParts) {
9596
+ if (this.containsInjectionPatterns(decoded)) {
8357
9597
  signals.push({
8358
- type: "data_exfiltration",
8359
- pattern: "structured_data_in_string",
8360
- location: path || "root",
8361
- severity: "medium"
9598
+ type: "encoding_evasion",
9599
+ pattern: "encoded_injection_payload",
9600
+ location: path,
9601
+ severity: "high"
8362
9602
  });
9603
+ return;
8363
9604
  }
8364
9605
  }
8365
9606
  }
8366
9607
  /**
8367
- * Detect prompt stuffing: very large strings or high repetition.
9608
+ * Check if a string contains any injection patterns (role override or security bypass).
8368
9609
  */
8369
- detectPromptStuffing(value, path, signals) {
8370
- if (value.length > 10240) {
8371
- signals.push({
8372
- type: "prompt_stuffing",
8373
- pattern: "large_string",
8374
- location: path || "root",
8375
- severity: "low"
8376
- });
9610
+ containsInjectionPatterns(value) {
9611
+ const normalized = this.normalizeConfusables(value.normalize("NFKC"));
9612
+ for (const pattern of ROLE_OVERRIDE_PATTERNS) {
9613
+ if (pattern.test(normalized)) return true;
8377
9614
  }
8378
- if (value.length >= 100) {
8379
- const windowSizes = [10, 20, 50];
8380
- for (const windowSize of windowSizes) {
8381
- if (value.length < windowSize * 5) continue;
8382
- const pattern = value.substring(0, windowSize);
8383
- let count = 0;
8384
- let idx = 0;
8385
- while (idx <= value.length - windowSize) {
8386
- if (value.substring(idx, idx + windowSize) === pattern) {
8387
- count++;
8388
- idx += windowSize;
8389
- } else {
8390
- idx++;
8391
- }
8392
- if (count >= 10) break;
8393
- }
8394
- if (count >= 10) {
8395
- signals.push({
8396
- type: "prompt_stuffing",
8397
- pattern: "high_repetition",
8398
- location: path || "root",
8399
- severity: "low"
8400
- });
8401
- break;
8402
- }
8403
- }
9615
+ for (const pattern of SECURITY_BYPASS_PATTERNS) {
9616
+ if (pattern.test(normalized)) return true;
8404
9617
  }
9618
+ return false;
8405
9619
  }
8406
9620
  /**
8407
- * Determine if this field is inherently safe from role override.
9621
+ * Safely decode a base64 string. Returns null if it's not valid base64
9622
+ * or doesn't decode to a meaningful string.
8408
9623
  */
8409
- isSafeField(path) {
9624
+ safeBase64Decode(value) {
9625
+ try {
9626
+ const cleaned = value.replace(/-/g, "+").replace(/_/g, "/");
9627
+ const padded = cleaned + "=".repeat((4 - cleaned.length % 4) % 4);
9628
+ if (!BASE64_STANDARD_PATTERN.test(padded)) return null;
9629
+ const decoded = Buffer.from(padded, "base64").toString("utf-8");
9630
+ if (this.looksLikeText(decoded)) {
9631
+ return decoded;
9632
+ }
9633
+ return null;
9634
+ } catch {
9635
+ return null;
9636
+ }
9637
+ }
9638
+ /**
9639
+ * Safely decode a hex string. Returns null on failure.
9640
+ */
9641
+ safeHexDecode(value) {
9642
+ try {
9643
+ const hex = value.startsWith("0x") ? value.slice(2) : value;
9644
+ if (hex.length % 2 !== 0) return null;
9645
+ const decoded = Buffer.from(hex, "hex").toString("utf-8");
9646
+ if (this.looksLikeText(decoded)) {
9647
+ return decoded;
9648
+ }
9649
+ return null;
9650
+ } catch {
9651
+ return null;
9652
+ }
9653
+ }
9654
+ /**
9655
+ * Decode HTML numeric entities (&#xHH; and &#DDD;) in a string.
9656
+ */
9657
+ decodeHtmlEntities(value) {
9658
+ return value.replace(/&#x([0-9a-fA-F]{2,4});/g, (_match, hex) => {
9659
+ try {
9660
+ return String.fromCodePoint(parseInt(hex, 16));
9661
+ } catch {
9662
+ return _match;
9663
+ }
9664
+ }).replace(/&#([0-9]{2,5});/g, (_match, dec) => {
9665
+ try {
9666
+ const cp = parseInt(dec, 10);
9667
+ if (cp > 1114111) return _match;
9668
+ return String.fromCodePoint(cp);
9669
+ } catch {
9670
+ return _match;
9671
+ }
9672
+ });
9673
+ }
9674
+ /**
9675
+ * Safely decode a URL-encoded string. Returns null on failure.
9676
+ */
9677
+ safeUrlDecode(value) {
9678
+ try {
9679
+ return decodeURIComponent(value);
9680
+ } catch {
9681
+ return null;
9682
+ }
9683
+ }
9684
+ /**
9685
+ * Heuristic: does this look like readable text (vs. binary garbage)?
9686
+ * Checks that most characters are printable ASCII or common Unicode.
9687
+ */
9688
+ looksLikeText(value) {
9689
+ if (value.length === 0) return false;
9690
+ let printable = 0;
9691
+ for (let i = 0; i < value.length; i++) {
9692
+ const code = value.charCodeAt(i);
9693
+ if (code >= 32 && code <= 126 || code === 10 || code === 13 || code === 9) {
9694
+ printable++;
9695
+ } else if (code >= 128) {
9696
+ if (code < 160) continue;
9697
+ printable++;
9698
+ }
9699
+ }
9700
+ return printable / value.length > 0.7;
9701
+ }
9702
+ // ═══════════════════════════════════════════════════════════════════════════
9703
+ // SEC-035: Outbound content scanning helpers
9704
+ // ═══════════════════════════════════════════════════════════════════════════
9705
+ /**
9706
+ * Detect API keys and secrets in outbound content.
9707
+ */
9708
+ detectSecretPatterns(content, signals) {
9709
+ for (const { pattern, name } of SECRET_PATTERNS) {
9710
+ if (pattern.test(content)) {
9711
+ signals.push({
9712
+ type: "secret_leak",
9713
+ pattern: name,
9714
+ location: "outbound",
9715
+ severity: "high"
9716
+ });
9717
+ }
9718
+ }
9719
+ }
9720
+ /**
9721
+ * Detect data exfiltration via markdown images with data-carrying query params.
9722
+ */
9723
+ detectOutboundExfiltration(content, signals) {
9724
+ if (MARKDOWN_IMAGE_EXFIL_PATTERN.test(content)) {
9725
+ signals.push({
9726
+ type: "data_exfiltration",
9727
+ pattern: "markdown_image_exfil",
9728
+ location: "outbound",
9729
+ severity: "high"
9730
+ });
9731
+ }
9732
+ }
9733
+ /**
9734
+ * Detect internal filesystem path leaks in outbound content.
9735
+ */
9736
+ detectInternalPathLeaks(content, signals) {
9737
+ for (const pattern of INTERNAL_PATH_PATTERNS) {
9738
+ if (pattern.test(content)) {
9739
+ signals.push({
9740
+ type: "internal_path_leak",
9741
+ pattern: pattern.source,
9742
+ location: "outbound",
9743
+ severity: "medium"
9744
+ });
9745
+ return;
9746
+ }
9747
+ }
9748
+ }
9749
+ /**
9750
+ * Detect private IP addresses and localhost references in outbound content.
9751
+ */
9752
+ detectPrivateNetworkLeaks(content, signals) {
9753
+ for (const pattern of PRIVATE_NETWORK_PATTERNS) {
9754
+ if (pattern.test(content)) {
9755
+ signals.push({
9756
+ type: "private_network_leak",
9757
+ pattern: pattern.source,
9758
+ location: "outbound",
9759
+ severity: "medium"
9760
+ });
9761
+ return;
9762
+ }
9763
+ }
9764
+ }
9765
+ /**
9766
+ * Detect role markers / prompt template artifacts in outbound content.
9767
+ * These should never appear in agent output — their presence indicates
9768
+ * injection artifact survival.
9769
+ */
9770
+ detectOutputRoleMarkers(content, signals) {
9771
+ for (const pattern of OUTPUT_ROLE_MARKER_PATTERNS) {
9772
+ if (pattern.test(content)) {
9773
+ signals.push({
9774
+ type: "injection_artifact",
9775
+ pattern: pattern.source,
9776
+ location: "outbound",
9777
+ severity: "high"
9778
+ });
9779
+ return;
9780
+ }
9781
+ }
9782
+ }
9783
+ // ═══════════════════════════════════════════════════════════════════════════
9784
+ // Existing detection methods (enhanced)
9785
+ // ═══════════════════════════════════════════════════════════════════════════
9786
+ /**
9787
+ * Detect base64 strings, base64url, and zero-width character evasion.
9788
+ */
9789
+ detectEncodingEvasion(value, path, signals) {
9790
+ const trimmed = value.trim();
9791
+ if (value.length > 50) {
9792
+ if (BASE64_STANDARD_PATTERN.test(trimmed)) {
9793
+ signals.push({
9794
+ type: "encoding_evasion",
9795
+ pattern: "base64_string",
9796
+ location: path || "root",
9797
+ severity: "medium"
9798
+ });
9799
+ } else if (BASE64URL_PATTERN.test(trimmed) && /[_-]/.test(trimmed)) {
9800
+ signals.push({
9801
+ type: "encoding_evasion",
9802
+ pattern: "base64url_string",
9803
+ location: path || "root",
9804
+ severity: "medium"
9805
+ });
9806
+ }
9807
+ }
9808
+ let zeroWidthCount = 0;
9809
+ for (const char of ZERO_WIDTH_CHARS) {
9810
+ zeroWidthCount += (value.match(new RegExp(char, "g")) || []).length;
9811
+ }
9812
+ if (zeroWidthCount > 0) {
9813
+ signals.push({
9814
+ type: "encoding_evasion",
9815
+ pattern: "zero_width_characters",
9816
+ location: path || "root",
9817
+ severity: "medium"
9818
+ });
9819
+ }
9820
+ const hasLatin = /[a-zA-Z]/.test(value);
9821
+ const hasCJK = /[\u4E00-\u9FFF\u3040-\u309F\uAC00-\uD7AF]/.test(value);
9822
+ const hasArabic = /[\u0600-\u06FF]/.test(value);
9823
+ const hasCyrillic = /[\u0400-\u04FF]/.test(value);
9824
+ const unicodeCategories = [hasLatin, hasCJK, hasArabic, hasCyrillic].filter(
9825
+ (x) => x
9826
+ ).length;
9827
+ if (unicodeCategories >= 3) {
9828
+ signals.push({
9829
+ type: "encoding_evasion",
9830
+ pattern: "unicode_category_mixing",
9831
+ location: path || "root",
9832
+ severity: "medium"
9833
+ });
9834
+ }
9835
+ }
9836
+ /**
9837
+ * Detect URLs and emails in fields that shouldn't have them.
9838
+ */
9839
+ detectDataExfiltration(value, path, signals) {
9840
+ if (this.isUrlSafeField(path)) {
9841
+ return;
9842
+ }
9843
+ if (URL_PATTERN.test(value)) {
9844
+ signals.push({
9845
+ type: "data_exfiltration",
9846
+ pattern: "url_in_string",
9847
+ location: path || "root",
9848
+ severity: "medium"
9849
+ });
9850
+ }
9851
+ if (EMAIL_PATTERN.test(value) && !this.isEmailSafeField(path)) {
9852
+ signals.push({
9853
+ type: "data_exfiltration",
9854
+ pattern: "email_in_string",
9855
+ location: path || "root",
9856
+ severity: "medium"
9857
+ });
9858
+ }
9859
+ if (value.length > 30 && value.length < 1e4 && !this.isStructuredField(path)) {
9860
+ const hasJsonContent = /\{[^}]*"[^"]*"[^}]*\}/.test(value);
9861
+ const hasXmlContent = /<[^>]+>[\s\S]*?<\/[^>]+>/.test(value);
9862
+ if (hasJsonContent || hasXmlContent) {
9863
+ signals.push({
9864
+ type: "data_exfiltration",
9865
+ pattern: "structured_data_in_string",
9866
+ location: path || "root",
9867
+ severity: "medium"
9868
+ });
9869
+ }
9870
+ }
9871
+ }
9872
+ /**
9873
+ * Detect prompt stuffing: very large strings or high repetition.
9874
+ */
9875
+ detectPromptStuffing(value, path, signals) {
9876
+ if (value.length > 10240) {
9877
+ signals.push({
9878
+ type: "prompt_stuffing",
9879
+ pattern: "large_string",
9880
+ location: path || "root",
9881
+ severity: "low"
9882
+ });
9883
+ }
9884
+ if (value.length >= 100) {
9885
+ const windowSizes = [10, 20, 50];
9886
+ for (const windowSize of windowSizes) {
9887
+ if (value.length < windowSize * 5) continue;
9888
+ const pattern = value.substring(0, windowSize);
9889
+ let count = 0;
9890
+ let idx = 0;
9891
+ while (idx <= value.length - windowSize) {
9892
+ if (value.substring(idx, idx + windowSize) === pattern) {
9893
+ count++;
9894
+ idx += windowSize;
9895
+ } else {
9896
+ idx++;
9897
+ }
9898
+ if (count >= 10) break;
9899
+ }
9900
+ if (count >= 10) {
9901
+ signals.push({
9902
+ type: "prompt_stuffing",
9903
+ pattern: "high_repetition",
9904
+ location: path || "root",
9905
+ severity: "low"
9906
+ });
9907
+ break;
9908
+ }
9909
+ }
9910
+ }
9911
+ }
9912
+ /**
9913
+ * Determine if this field is inherently safe from role override.
9914
+ */
9915
+ isSafeField(path) {
8410
9916
  const safePaths = [
8411
9917
  /\.version$/i,
8412
9918
  /\.timestamp$/i,
@@ -8477,62 +9983,89 @@ var InjectionDetector = class {
8477
9983
  return structuredFields.some((p) => p.test(path));
8478
9984
  }
8479
9985
  /**
8480
- * SEC-032: Map common cross-script confusable characters to their Latin equivalents.
8481
- * NFKC normalization handles fullwidth and compatibility forms, but does NOT map
8482
- * Cyrillic/Greek lookalikes to Latin (they're distinct codepoints by design).
8483
- * This covers the most common confusables used in injection evasion.
9986
+ * SEC-032/SEC-034: Map common cross-script confusable characters to their
9987
+ * Latin equivalents. NFKC normalization handles fullwidth and compatibility
9988
+ * forms, but does NOT map Cyrillic/Greek/Armenian/Georgian lookalikes to
9989
+ * Latin (they're distinct codepoints by design).
9990
+ *
9991
+ * Extended to 50+ confusable pairs covering Cyrillic, Greek, Armenian,
9992
+ * Georgian, Cherokee, and mathematical/symbol lookalikes.
8484
9993
  */
8485
9994
  normalizeConfusables(value) {
8486
9995
  const confusables = {
8487
- // Cyrillic → Latin
9996
+ // ── Cyrillic → Latin ──────────────────────────────────────────────
8488
9997
  "\u0410": "A",
8489
9998
  "\u0430": "a",
8490
9999
  // А а
8491
10000
  "\u0412": "B",
8492
10001
  "\u0432": "b",
8493
- // В (not exact) в (not exact)
10002
+ // В в (visual approximation)
8494
10003
  "\u0421": "C",
8495
10004
  "\u0441": "c",
8496
10005
  // С с
10006
+ "\u0414": "D",
10007
+ // Д (visual approximation)
8497
10008
  "\u0415": "E",
8498
10009
  "\u0435": "e",
8499
10010
  // Е е
8500
10011
  "\u041D": "H",
8501
10012
  "\u043D": "h",
8502
- // Н (not exact) н (not exact)
10013
+ // Н н (visual approximation)
10014
+ "\u0406": "I",
10015
+ "\u0456": "i",
10016
+ // І і (Ukrainian I)
10017
+ "\u0408": "J",
10018
+ // Ј (Serbian Je)
8503
10019
  "\u041A": "K",
8504
10020
  "\u043A": "k",
8505
- // К к (not exact)
10021
+ // К к (visual approximation)
8506
10022
  "\u041C": "M",
8507
10023
  "\u043C": "m",
8508
- // М (not exact) м (not exact)
10024
+ // М м (visual approximation)
8509
10025
  "\u041E": "O",
8510
10026
  "\u043E": "o",
8511
10027
  // О о
8512
10028
  "\u0420": "P",
8513
10029
  "\u0440": "p",
8514
10030
  // Р р
10031
+ "\u0405": "S",
10032
+ "\u0455": "s",
10033
+ // Ѕ ѕ (Macedonian S)
8515
10034
  "\u0422": "T",
8516
10035
  "\u0442": "t",
8517
- // Т (not exact) т (not exact)
10036
+ // Т т (visual approximation)
8518
10037
  "\u0425": "X",
8519
10038
  "\u0445": "x",
8520
10039
  // Х х
8521
10040
  "\u0423": "Y",
8522
10041
  "\u0443": "y",
8523
- // У (not exact) у
8524
- // Greek → Latin
10042
+ // У у (visual approximation)
10043
+ "\u0417": "3",
10044
+ // З (looks like 3)
10045
+ "\u04BB": "h",
10046
+ // һ (Shha)
10047
+ "\u04C0": "I",
10048
+ // Ӏ (Palochka)
10049
+ "\u04CF": "l",
10050
+ // ӏ (Palochka small)
10051
+ // ── Greek → Latin ─────────────────────────────────────────────────
8525
10052
  "\u0391": "A",
8526
10053
  "\u03B1": "a",
8527
- // Α α (not exact)
10054
+ // Α α (alpha not exact)
8528
10055
  "\u0392": "B",
8529
10056
  "\u03B2": "b",
8530
10057
  // Β β (not exact)
10058
+ "\u0393": "G",
10059
+ // Γ (visual approximation)
8531
10060
  "\u0395": "E",
8532
10061
  "\u03B5": "e",
8533
10062
  // Ε ε (not exact)
10063
+ "\u0396": "Z",
10064
+ "\u03B6": "z",
10065
+ // Ζ ζ (not exact)
8534
10066
  "\u0397": "H",
8535
- // Η
10067
+ "\u03B7": "n",
10068
+ // Η η (not exact)
8536
10069
  "\u0399": "I",
8537
10070
  "\u03B9": "i",
8538
10071
  // Ι ι
@@ -8556,8 +10089,78 @@ var InjectionDetector = class {
8556
10089
  "\u03C5": "y",
8557
10090
  // Υ υ (not exact)
8558
10091
  "\u03A7": "X",
8559
- "\u03C7": "x"
10092
+ "\u03C7": "x",
8560
10093
  // Χ χ (not exact)
10094
+ "\u03C9": "w",
10095
+ // ω (omega, visual approximation)
10096
+ // ── Armenian → Latin ──────────────────────────────────────────────
10097
+ "\u0555": "O",
10098
+ // Օ
10099
+ "\u0585": "o",
10100
+ // օ
10101
+ "\u054D": "S",
10102
+ // Ս
10103
+ "\u057D": "s",
10104
+ // ս
10105
+ "\u054C": "L",
10106
+ // Լ (visual approximation)
10107
+ "\u0570": "h",
10108
+ // հ
10109
+ // ── Cherokee → Latin ──────────────────────────────────────────────
10110
+ "\u13A0": "D",
10111
+ // Ꭰ
10112
+ "\u13B3": "W",
10113
+ // Ꮃ
10114
+ "\u13A1": "R",
10115
+ // Ꭱ
10116
+ "\u13AA": "G",
10117
+ // Ꭺ (looks like A but maps to G sound)
10118
+ "\u13D2": "V",
10119
+ // Ꮢ (visual approximation)
10120
+ // ── Georgian → Latin ──────────────────────────────────────────────
10121
+ "\u10D5": "v",
10122
+ // ვ (Georgian letter vin)
10123
+ "\u10D3": "d",
10124
+ // დ (Georgian letter don)
10125
+ "\u10DA": "l",
10126
+ // ლ (Georgian letter las)
10127
+ // ── Latin special → Latin ────────────────────────────────────────
10128
+ "\u0131": "i",
10129
+ // ı (Latin small letter dotless i)
10130
+ // ── Symbols / Mathematical → Latin ────────────────────────────────
10131
+ // Note: NFKC normalization handles mathematical alphanumerics (U+1D400–U+1D7FF)
10132
+ "\u2160": "I",
10133
+ // Ⅰ (Roman numeral one)
10134
+ "\u2164": "V",
10135
+ // Ⅴ (Roman numeral five)
10136
+ "\u2169": "X",
10137
+ // Ⅹ (Roman numeral ten)
10138
+ "\u216C": "L",
10139
+ // Ⅼ (Roman numeral fifty)
10140
+ "\u216D": "C",
10141
+ // Ⅽ (Roman numeral one hundred)
10142
+ "\u216E": "D",
10143
+ // Ⅾ (Roman numeral five hundred)
10144
+ "\u216F": "M",
10145
+ // Ⅿ (Roman numeral one thousand)
10146
+ "\u2170": "i",
10147
+ // ⅰ (small Roman numeral one)
10148
+ "\u2174": "v",
10149
+ // ⅴ (small Roman numeral five)
10150
+ "\u2179": "x",
10151
+ // ⅹ (small Roman numeral ten)
10152
+ "\u217C": "l",
10153
+ // ⅼ (small Roman numeral fifty)
10154
+ "\u217D": "c",
10155
+ // ⅽ (small Roman numeral one hundred)
10156
+ "\u217E": "d",
10157
+ // ⅾ (small Roman numeral five hundred)
10158
+ "\u217F": "m",
10159
+ // ⅿ (small Roman numeral one thousand)
10160
+ "\u0251": "a",
10161
+ // ɑ (Latin alpha — looks like 'a')
10162
+ "\u0261": "g"
10163
+ // ɡ (Latin small letter script G)
8561
10164
  };
8562
10165
  let result = value;
8563
10166
  if (/[^\x00-\x7F]/.test(value)) {
@@ -8647,6 +10250,7 @@ var ApprovalGate = class {
8647
10250
  auditLog;
8648
10251
  injectionDetector;
8649
10252
  onInjectionAlert;
10253
+ proxyTierResolver;
8650
10254
  constructor(policy, baseline, channel, auditLog, injectionDetector, onInjectionAlert) {
8651
10255
  this.policy = policy;
8652
10256
  this.baseline = baseline;
@@ -8655,6 +10259,12 @@ var ApprovalGate = class {
8655
10259
  this.injectionDetector = injectionDetector ?? new InjectionDetector();
8656
10260
  this.onInjectionAlert = onInjectionAlert;
8657
10261
  }
10262
+ /**
10263
+ * Set the proxy tier resolver. Called after the proxy router is initialized.
10264
+ */
10265
+ setProxyTierResolver(resolver) {
10266
+ this.proxyTierResolver = resolver;
10267
+ }
8658
10268
  /**
8659
10269
  * Evaluate a tool call against the Principal Policy.
8660
10270
  *
@@ -8707,6 +10317,38 @@ var ApprovalGate = class {
8707
10317
  );
8708
10318
  }
8709
10319
  }
10320
+ if (toolName.startsWith("proxy/") && this.proxyTierResolver) {
10321
+ const proxyTier = this.proxyTierResolver(toolName);
10322
+ if (proxyTier !== null) {
10323
+ if (proxyTier === 1) {
10324
+ return this.requestApproval(operation, 1, `Proxy tool "${toolName}" is configured as Tier 1 (always requires approval)`, {
10325
+ operation: toolName,
10326
+ proxy: true,
10327
+ args_summary: this.summarizeArgs(args)
10328
+ });
10329
+ }
10330
+ if (proxyTier === 2) {
10331
+ const anomaly2 = this.detectAnomaly(operation, args);
10332
+ if (anomaly2) {
10333
+ return this.requestApproval(operation, 2, `Proxy: ${anomaly2.reason}`, {
10334
+ ...anomaly2.context,
10335
+ proxy: true
10336
+ });
10337
+ }
10338
+ }
10339
+ this.auditLog.append("l2", `gate_allow_proxy:${toolName}`, "system", {
10340
+ tier: proxyTier,
10341
+ operation: toolName,
10342
+ proxy: true
10343
+ });
10344
+ return {
10345
+ allowed: true,
10346
+ tier: proxyTier,
10347
+ reason: `Proxy operation allowed (Tier ${proxyTier})`,
10348
+ approval_required: false
10349
+ };
10350
+ }
10351
+ }
8710
10352
  if (this.policy.tier1_always_approve.includes(operation)) {
8711
10353
  return this.requestApproval(operation, 1, `"${operation}" is a Tier 1 operation (always requires approval)`, {
8712
10354
  operation,
@@ -10406,7 +12048,7 @@ function createBridgeCommitment(outcome, identity, identityEncryptionKey, includ
10406
12048
  const now = (/* @__PURE__ */ new Date()).toISOString();
10407
12049
  const canonicalBytes = canonicalize(outcome);
10408
12050
  const canonicalString = new TextDecoder().decode(canonicalBytes);
10409
- const sha2564 = createCommitment(canonicalString);
12051
+ const sha2565 = createCommitment(canonicalString);
10410
12052
  let pedersenData;
10411
12053
  if (includePedersen && Number.isInteger(outcome.rounds) && outcome.rounds >= 0) {
10412
12054
  const pedersen = createPedersenCommitment(outcome.rounds);
@@ -10418,7 +12060,7 @@ function createBridgeCommitment(outcome, identity, identityEncryptionKey, includ
10418
12060
  const commitmentPayload = {
10419
12061
  bridge_commitment_id: commitmentId,
10420
12062
  session_id: outcome.session_id,
10421
- sha256_commitment: sha2564.commitment,
12063
+ sha256_commitment: sha2565.commitment,
10422
12064
  terms_hash: outcome.terms_hash,
10423
12065
  committer_did: identity.did,
10424
12066
  committed_at: now,
@@ -10429,8 +12071,8 @@ function createBridgeCommitment(outcome, identity, identityEncryptionKey, includ
10429
12071
  return {
10430
12072
  bridge_commitment_id: commitmentId,
10431
12073
  session_id: outcome.session_id,
10432
- sha256_commitment: sha2564.commitment,
10433
- blinding_factor: sha2564.blinding_factor,
12074
+ sha256_commitment: sha2565.commitment,
12075
+ blinding_factor: sha2565.blinding_factor,
10434
12076
  committer_did: identity.did,
10435
12077
  signature: toBase64url(signature),
10436
12078
  pedersen_commitment: pedersenData,
@@ -13633,42 +15275,878 @@ function createSovereigntyProfileTools(profileStore, auditLog) {
13633
15275
  ];
13634
15276
  return { tools };
13635
15277
  }
13636
-
13637
- // src/index.ts
13638
- init_key_derivation();
13639
- init_random();
13640
- init_encoding();
13641
- init_config();
13642
- init_audit_log();
13643
- init_sovereignty_profile();
13644
- init_system_prompt_generator();
13645
- init_filesystem();
13646
- init_baseline();
13647
- init_loader();
13648
- init_dashboard();
13649
- init_generator();
13650
- async function createSanctuaryServer(options) {
13651
- const config = await loadConfig(options?.configPath);
13652
- await promises.mkdir(config.storage_path, { recursive: true, mode: 448 });
13653
- const storage = options?.storage ?? new FilesystemStorage(
13654
- `${config.storage_path}/state`
13655
- );
13656
- let masterKey;
13657
- let keyProtection;
13658
- let recoveryKey;
13659
- const passphrase = options?.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
13660
- if (passphrase) {
13661
- keyProtection = "passphrase";
13662
- let existingParams;
13663
- try {
13664
- const raw = await storage.read("_meta", "key-params");
13665
- if (raw) {
13666
- const { bytesToString: bytesToString2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
13667
- existingParams = JSON.parse(bytesToString2(raw));
15278
+ var MAX_RETRIES = 5;
15279
+ var BASE_BACKOFF_MS = 1e3;
15280
+ var MAX_BACKOFF_MS = 3e4;
15281
+ var MAX_UPSTREAM_SERVERS = 20;
15282
+ var ClientManager = class {
15283
+ connections = /* @__PURE__ */ new Map();
15284
+ onStateChange;
15285
+ shutdownRequested = false;
15286
+ constructor(options) {
15287
+ this.onStateChange = options?.onStateChange;
15288
+ }
15289
+ /**
15290
+ * Configure upstream servers. Disconnects removed servers, connects new ones.
15291
+ * Non-blocking — connection failures are handled asynchronously.
15292
+ */
15293
+ async configure(servers) {
15294
+ if (servers.length > MAX_UPSTREAM_SERVERS) {
15295
+ throw new Error(`Maximum ${MAX_UPSTREAM_SERVERS} upstream servers allowed`);
15296
+ }
15297
+ const SAFE_SERVER_NAME = /^[a-zA-Z0-9_\-]+$/;
15298
+ const newNames = new Set(servers.filter((s) => {
15299
+ if (!SAFE_SERVER_NAME.test(s.name)) {
15300
+ return false;
15301
+ }
15302
+ return s.enabled;
15303
+ }).map((s) => s.name));
15304
+ for (const [name] of this.connections) {
15305
+ if (!newNames.has(name)) {
15306
+ await this.disconnectServer(name);
13668
15307
  }
13669
- } catch {
13670
15308
  }
13671
- const result = await deriveMasterKey(passphrase, existingParams);
15309
+ for (const server of servers) {
15310
+ if (!SAFE_SERVER_NAME.test(server.name)) {
15311
+ continue;
15312
+ }
15313
+ if (!server.enabled) {
15314
+ if (this.connections.has(server.name)) {
15315
+ await this.disconnectServer(server.name);
15316
+ }
15317
+ continue;
15318
+ }
15319
+ const existing = this.connections.get(server.name);
15320
+ if (existing && existing.state === "connected") {
15321
+ existing.server = server;
15322
+ continue;
15323
+ }
15324
+ this.connectServer(server);
15325
+ }
15326
+ }
15327
+ /**
15328
+ * Get all discovered tools across all connected upstream servers.
15329
+ */
15330
+ getAllTools() {
15331
+ const result = /* @__PURE__ */ new Map();
15332
+ for (const [name, conn] of this.connections) {
15333
+ if (conn.state === "connected" && conn.tools.length > 0) {
15334
+ result.set(name, conn.tools);
15335
+ }
15336
+ }
15337
+ return result;
15338
+ }
15339
+ /**
15340
+ * Get connection status for all configured servers.
15341
+ */
15342
+ getStatus() {
15343
+ return Array.from(this.connections.values()).map((conn) => ({
15344
+ name: conn.server.name,
15345
+ state: conn.state,
15346
+ transport_type: conn.server.transport.type,
15347
+ tool_count: conn.tools.length,
15348
+ error: conn.error
15349
+ }));
15350
+ }
15351
+ /**
15352
+ * Get the upstream server config by name.
15353
+ */
15354
+ getServerConfig(name) {
15355
+ return this.connections.get(name)?.server;
15356
+ }
15357
+ /**
15358
+ * Call a tool on an upstream server.
15359
+ */
15360
+ async callTool(serverName, toolName, args) {
15361
+ const conn = this.connections.get(serverName);
15362
+ if (!conn) {
15363
+ throw new Error(`Upstream server "${serverName}" is not configured`);
15364
+ }
15365
+ if (conn.state !== "connected" || !conn.client) {
15366
+ throw new Error(`Upstream server "${serverName}" is not connected (state: ${conn.state})`);
15367
+ }
15368
+ const result = await conn.client.callTool({
15369
+ name: toolName,
15370
+ arguments: args
15371
+ });
15372
+ return result;
15373
+ }
15374
+ /**
15375
+ * Shut down all connections cleanly.
15376
+ */
15377
+ async shutdown() {
15378
+ this.shutdownRequested = true;
15379
+ for (const conn of this.connections.values()) {
15380
+ if (conn.retryTimer) {
15381
+ clearTimeout(conn.retryTimer);
15382
+ conn.retryTimer = void 0;
15383
+ }
15384
+ }
15385
+ const disconnects = Array.from(this.connections.keys()).map(
15386
+ (name) => this.disconnectServer(name)
15387
+ );
15388
+ await Promise.allSettled(disconnects);
15389
+ }
15390
+ // ── Private ───────────────────────────────────────────────────────────
15391
+ /**
15392
+ * Connect to an upstream server (non-blocking).
15393
+ * Spawns connection attempt in background — does not throw.
15394
+ */
15395
+ connectServer(server) {
15396
+ const conn = {
15397
+ server,
15398
+ client: null,
15399
+ transport: null,
15400
+ state: "connecting",
15401
+ tools: [],
15402
+ retryCount: 0
15403
+ };
15404
+ this.connections.set(server.name, conn);
15405
+ this.notifyStateChange(conn);
15406
+ this.doConnect(conn).catch(() => {
15407
+ });
15408
+ }
15409
+ /**
15410
+ * Perform the actual connection to an upstream server.
15411
+ */
15412
+ async doConnect(conn) {
15413
+ try {
15414
+ conn.state = "connecting";
15415
+ this.notifyStateChange(conn);
15416
+ let transport;
15417
+ if (conn.server.transport.type === "stdio") {
15418
+ if (!conn.server.transport.command) {
15419
+ throw new Error("stdio transport requires a command");
15420
+ }
15421
+ if (conn.server.transport.args) {
15422
+ const SAFE_ARG_PATTERN = /^[a-zA-Z0-9._\-\/=:@]+$/;
15423
+ for (const arg of conn.server.transport.args) {
15424
+ if (!SAFE_ARG_PATTERN.test(arg)) {
15425
+ throw new Error(`Unsafe argument rejected: contains disallowed characters`);
15426
+ }
15427
+ }
15428
+ }
15429
+ const ENV_BLOCKLIST = /* @__PURE__ */ new Set([
15430
+ "PATH",
15431
+ "HOME",
15432
+ "USER",
15433
+ "SHELL",
15434
+ "NODE_OPTIONS",
15435
+ "NODE_PATH",
15436
+ "LD_PRELOAD",
15437
+ "LD_LIBRARY_PATH",
15438
+ "DYLD_INSERT_LIBRARIES",
15439
+ "PYTHONPATH",
15440
+ "RUBYLIB",
15441
+ "PERL5LIB",
15442
+ "HTTP_PROXY",
15443
+ "HTTPS_PROXY",
15444
+ "NO_PROXY",
15445
+ "http_proxy",
15446
+ "https_proxy",
15447
+ "no_proxy"
15448
+ ]);
15449
+ let transportEnv;
15450
+ if (conn.server.transport.env) {
15451
+ const safeEnv = { ...process.env };
15452
+ for (const [key, value] of Object.entries(conn.server.transport.env)) {
15453
+ if (!ENV_BLOCKLIST.has(key)) {
15454
+ safeEnv[key] = value;
15455
+ }
15456
+ }
15457
+ transportEnv = safeEnv;
15458
+ }
15459
+ transport = new stdio_js$1.StdioClientTransport({
15460
+ command: conn.server.transport.command,
15461
+ args: conn.server.transport.args,
15462
+ env: transportEnv
15463
+ });
15464
+ } else {
15465
+ if (!conn.server.transport.url) {
15466
+ throw new Error("sse transport requires a url");
15467
+ }
15468
+ const ssrfUrl = new URL(conn.server.transport.url);
15469
+ if (ssrfUrl.protocol !== "http:" && ssrfUrl.protocol !== "https:") {
15470
+ throw new Error("SSE transport URL must use http or https scheme");
15471
+ }
15472
+ transport = new sse_js.SSEClientTransport(ssrfUrl);
15473
+ }
15474
+ const client = new index_js.Client(
15475
+ { name: `sanctuary-proxy/${conn.server.name}`, version: "1.0.0" },
15476
+ { capabilities: {} }
15477
+ );
15478
+ await client.connect(transport);
15479
+ conn.client = client;
15480
+ conn.transport = transport;
15481
+ conn.state = "connected";
15482
+ conn.error = void 0;
15483
+ conn.retryCount = 0;
15484
+ await this.discoverTools(conn);
15485
+ this.notifyStateChange(conn);
15486
+ } catch (err) {
15487
+ const message = err instanceof Error ? err.message : "Unknown connection error";
15488
+ conn.state = "error";
15489
+ conn.error = message;
15490
+ conn.client = null;
15491
+ conn.transport = null;
15492
+ this.notifyStateChange(conn);
15493
+ this.scheduleRetry(conn);
15494
+ }
15495
+ }
15496
+ /**
15497
+ * Discover tools from a connected upstream server.
15498
+ */
15499
+ async discoverTools(conn) {
15500
+ if (!conn.client || conn.state !== "connected") return;
15501
+ try {
15502
+ const result = await conn.client.listTools();
15503
+ conn.tools = (result.tools ?? []).map((t) => ({
15504
+ name: t.name,
15505
+ description: t.description ?? "",
15506
+ inputSchema: t.inputSchema ?? { type: "object", properties: {} }
15507
+ }));
15508
+ } catch {
15509
+ conn.tools = [];
15510
+ }
15511
+ }
15512
+ /**
15513
+ * Schedule a reconnection attempt with exponential backoff.
15514
+ */
15515
+ scheduleRetry(conn) {
15516
+ if (this.shutdownRequested) return;
15517
+ if (conn.retryCount >= MAX_RETRIES) {
15518
+ conn.error = `Max retries (${MAX_RETRIES}) exceeded. Last error: ${conn.error}`;
15519
+ this.notifyStateChange(conn);
15520
+ return;
15521
+ }
15522
+ const delay = Math.min(
15523
+ BASE_BACKOFF_MS * Math.pow(2, conn.retryCount),
15524
+ MAX_BACKOFF_MS
15525
+ );
15526
+ conn.retryCount++;
15527
+ conn.retryTimer = setTimeout(() => {
15528
+ if (this.shutdownRequested) return;
15529
+ conn.retryTimer = void 0;
15530
+ this.doConnect(conn).catch(() => {
15531
+ });
15532
+ }, delay);
15533
+ }
15534
+ /**
15535
+ * Disconnect a specific upstream server.
15536
+ */
15537
+ async disconnectServer(name) {
15538
+ const conn = this.connections.get(name);
15539
+ if (!conn) return;
15540
+ if (conn.retryTimer) {
15541
+ clearTimeout(conn.retryTimer);
15542
+ conn.retryTimer = void 0;
15543
+ }
15544
+ if (conn.client) {
15545
+ try {
15546
+ await conn.client.close();
15547
+ } catch {
15548
+ }
15549
+ }
15550
+ if (conn.transport) {
15551
+ try {
15552
+ await conn.transport.close();
15553
+ } catch {
15554
+ }
15555
+ }
15556
+ this.connections.delete(name);
15557
+ }
15558
+ /**
15559
+ * Notify listener of state change.
15560
+ */
15561
+ notifyStateChange(conn) {
15562
+ if (this.onStateChange) {
15563
+ try {
15564
+ this.onStateChange(conn.server.name, conn.state, conn.tools.length, conn.error);
15565
+ } catch {
15566
+ }
15567
+ }
15568
+ }
15569
+ };
15570
+
15571
+ // src/proxy/proxy-router.ts
15572
+ init_router();
15573
+ var UPSTREAM_CALL_TIMEOUT_MS = 3e4;
15574
+ var ProxyRouter = class {
15575
+ clientManager;
15576
+ injectionDetector;
15577
+ auditLog;
15578
+ options;
15579
+ constructor(clientManager, injectionDetector, auditLog, options) {
15580
+ this.clientManager = clientManager;
15581
+ this.injectionDetector = injectionDetector;
15582
+ this.auditLog = auditLog;
15583
+ this.options = options ?? {};
15584
+ }
15585
+ /**
15586
+ * Convert all discovered upstream tools to Sanctuary ToolDefinitions.
15587
+ * Each tool is registered as `proxy/{server_name}/{tool_name}`.
15588
+ */
15589
+ getProxiedTools() {
15590
+ const tools = [];
15591
+ const allUpstreamTools = this.clientManager.getAllTools();
15592
+ for (const [serverName, serverTools] of allUpstreamTools) {
15593
+ for (const upstreamTool of serverTools) {
15594
+ const proxyName = `proxy/${serverName}/${upstreamTool.name}`;
15595
+ tools.push({
15596
+ name: proxyName,
15597
+ description: `[via ${serverName}] ${upstreamTool.description}`,
15598
+ inputSchema: upstreamTool.inputSchema,
15599
+ handler: this.createHandler(serverName, upstreamTool.name)
15600
+ });
15601
+ }
15602
+ }
15603
+ return tools;
15604
+ }
15605
+ /**
15606
+ * Determine the tier for a proxied tool call.
15607
+ * Checks tool_overrides first, then falls back to default_tier.
15608
+ */
15609
+ getTierForTool(serverName, toolName) {
15610
+ const serverConfig = this.clientManager.getServerConfig(serverName);
15611
+ if (!serverConfig) return 2;
15612
+ if (serverConfig.tool_overrides?.[toolName]) {
15613
+ return serverConfig.tool_overrides[toolName].tier;
15614
+ }
15615
+ return serverConfig.default_tier;
15616
+ }
15617
+ /**
15618
+ * Parse a proxy tool name into server name and tool name.
15619
+ * Returns null if the name doesn't match the proxy namespace.
15620
+ */
15621
+ static parseProxyToolName(fullName) {
15622
+ if (!fullName.startsWith("proxy/")) return null;
15623
+ const rest = fullName.slice("proxy/".length);
15624
+ const slashIdx = rest.indexOf("/");
15625
+ if (slashIdx === -1) return null;
15626
+ return {
15627
+ serverName: rest.slice(0, slashIdx),
15628
+ toolName: rest.slice(slashIdx + 1)
15629
+ };
15630
+ }
15631
+ // ── Private ───────────────────────────────────────────────────────────
15632
+ /**
15633
+ * Create a handler for a specific proxied tool.
15634
+ * The handler runs the full enforcement chain before forwarding.
15635
+ */
15636
+ createHandler(serverName, toolName) {
15637
+ return async (args) => {
15638
+ const proxyName = `proxy/${serverName}/${toolName}`;
15639
+ const start = Date.now();
15640
+ const tier = this.getTierForTool(serverName, toolName);
15641
+ try {
15642
+ const injectionResult = this.injectionDetector.scan(proxyName, args);
15643
+ if (injectionResult.flagged && injectionResult.recommendation === "block") {
15644
+ this.auditLog.append("l2", `proxy_injection_blocked:${proxyName}`, "system", {
15645
+ server: serverName,
15646
+ tool: toolName,
15647
+ tier,
15648
+ confidence: injectionResult.confidence,
15649
+ latency_ms: Date.now() - start
15650
+ }, "failure");
15651
+ return toolResult({
15652
+ error: "Operation not permitted",
15653
+ proxy: true
15654
+ });
15655
+ }
15656
+ if (injectionResult.flagged && injectionResult.recommendation === "escalate") {
15657
+ this.auditLog.append("l2", `proxy_injection_escalated:${proxyName}`, "system", {
15658
+ server: serverName,
15659
+ tool: toolName,
15660
+ tier,
15661
+ confidence: injectionResult.confidence
15662
+ });
15663
+ }
15664
+ let filteredArgs = args;
15665
+ if (this.options.contextGateFilter) {
15666
+ try {
15667
+ filteredArgs = await this.options.contextGateFilter(proxyName, args);
15668
+ } catch {
15669
+ }
15670
+ }
15671
+ if (this.options.governor) {
15672
+ const govResult = this.options.governor.check(serverName, toolName, filteredArgs);
15673
+ if (!govResult.allowed) {
15674
+ this.auditLog.append("l2", `proxy_governor_blocked:${proxyName}`, "system", {
15675
+ server: serverName,
15676
+ tool: toolName,
15677
+ tier,
15678
+ reason: govResult.reason,
15679
+ latency_ms: Date.now() - start
15680
+ }, "failure");
15681
+ return toolResult({
15682
+ error: "Operation not permitted",
15683
+ proxy: true,
15684
+ governor_reason: govResult.reason
15685
+ });
15686
+ }
15687
+ if (govResult.reason === "duplicate_cached" && govResult.cached_result !== void 0) {
15688
+ this.auditLog.append("l2", `proxy_governor_cached:${proxyName}`, "system", {
15689
+ server: serverName,
15690
+ tool: toolName,
15691
+ tier,
15692
+ cached: true,
15693
+ latency_ms: Date.now() - start
15694
+ });
15695
+ return toolResult(govResult.cached_result ?? {});
15696
+ }
15697
+ }
15698
+ const result = await this.callWithTimeout(
15699
+ serverName,
15700
+ toolName,
15701
+ filteredArgs,
15702
+ UPSTREAM_CALL_TIMEOUT_MS
15703
+ );
15704
+ const latencyMs = Date.now() - start;
15705
+ if (this.options.governor) {
15706
+ this.options.governor.recordResult(serverName, toolName, filteredArgs, result);
15707
+ }
15708
+ this.auditLog.append("l2", `proxy_call:${proxyName}`, "system", {
15709
+ server: serverName,
15710
+ tool: toolName,
15711
+ tier,
15712
+ decision: "allowed",
15713
+ latency_ms: latencyMs
15714
+ });
15715
+ return this.normalizeResponse(result);
15716
+ } catch (err) {
15717
+ const latencyMs = Date.now() - start;
15718
+ const rawErrorMessage = err instanceof Error ? err.message : "Unknown upstream error";
15719
+ const sanitizeError = (msg) => {
15720
+ let safe = msg.substring(0, 200);
15721
+ safe = safe.replace(/\/[^\s]+/g, "[path-redacted]");
15722
+ safe = safe.replace(/(?:mongodb|postgres|mysql|redis):\/\/[^\s]+/g, "[connection-redacted]");
15723
+ return safe;
15724
+ };
15725
+ const errorMessage = sanitizeError(rawErrorMessage);
15726
+ this.auditLog.append("l2", `proxy_call:${proxyName}`, "system", {
15727
+ server: serverName,
15728
+ tool: toolName,
15729
+ tier,
15730
+ decision: "error",
15731
+ error: errorMessage,
15732
+ latency_ms: latencyMs
15733
+ }, "failure");
15734
+ return {
15735
+ content: [{
15736
+ type: "text",
15737
+ text: JSON.stringify({
15738
+ error: errorMessage,
15739
+ proxy: true,
15740
+ server: serverName,
15741
+ tool: toolName
15742
+ })
15743
+ }]
15744
+ };
15745
+ }
15746
+ };
15747
+ }
15748
+ /**
15749
+ * Call an upstream tool with a timeout.
15750
+ */
15751
+ async callWithTimeout(serverName, toolName, args, timeoutMs) {
15752
+ return new Promise((resolve, reject) => {
15753
+ const timer = setTimeout(() => {
15754
+ reject(new Error(`Upstream tool call timed out after ${timeoutMs}ms`));
15755
+ }, timeoutMs);
15756
+ this.clientManager.callTool(serverName, toolName, args).then((result) => {
15757
+ clearTimeout(timer);
15758
+ resolve(result);
15759
+ }).catch((err) => {
15760
+ clearTimeout(timer);
15761
+ reject(err);
15762
+ });
15763
+ });
15764
+ }
15765
+ /**
15766
+ * Normalize an upstream response to the standard Sanctuary response format.
15767
+ */
15768
+ normalizeResponse(result) {
15769
+ const MAX_RESPONSE_SIZE = 1e6;
15770
+ const MAX_TEXT_BLOCK_SIZE = 1e5;
15771
+ const responseStr = JSON.stringify(result);
15772
+ if (responseStr.length > MAX_RESPONSE_SIZE) {
15773
+ return toolResult({
15774
+ error: "upstream_response_too_large",
15775
+ max_bytes: MAX_RESPONSE_SIZE
15776
+ });
15777
+ }
15778
+ if (!result.content || !Array.isArray(result.content)) {
15779
+ return toolResult({ upstream_response: result });
15780
+ }
15781
+ const textContent = result.content.filter((c) => c.type === "text" && typeof c.text === "string").map((c) => {
15782
+ const text = c.text;
15783
+ if (text.length > MAX_TEXT_BLOCK_SIZE) {
15784
+ return {
15785
+ type: "text",
15786
+ text: text.substring(0, MAX_TEXT_BLOCK_SIZE) + "\n[response truncated]"
15787
+ };
15788
+ }
15789
+ return { type: "text", text };
15790
+ });
15791
+ if (textContent.length > 0) {
15792
+ return { content: textContent };
15793
+ }
15794
+ return toolResult({ upstream_response: result.content });
15795
+ }
15796
+ };
15797
+ function strToBytes(s) {
15798
+ return new TextEncoder().encode(s);
15799
+ }
15800
+ function bytesToHex(bytes) {
15801
+ let hex = "";
15802
+ for (let i = 0; i < bytes.length; i++) {
15803
+ hex += bytes[i].toString(16).padStart(2, "0");
15804
+ }
15805
+ return hex;
15806
+ }
15807
+ function sha256Hex(input) {
15808
+ return bytesToHex(sha256.sha256(strToBytes(input)));
15809
+ }
15810
+ var DEFAULT_CONFIG = {
15811
+ volume_limit: 200,
15812
+ volume_window_ms: 6e5,
15813
+ // 10 minutes
15814
+ rate_limit: 20,
15815
+ rate_window_ms: 6e4,
15816
+ // 1 minute
15817
+ duplicate_ttl_ms: 6e4,
15818
+ // 1 minute
15819
+ lifetime_limit: 1e3
15820
+ };
15821
+ var CallGovernor = class {
15822
+ config;
15823
+ /** Sliding window of all call timestamps for volume tracking */
15824
+ volumeWindow = [];
15825
+ /** Per-tool sliding window of call timestamps for rate tracking */
15826
+ rateWindows = /* @__PURE__ */ new Map();
15827
+ /** Duplicate cache: SHA-256(server+tool+args) -> cached result + expiry */
15828
+ duplicateCache = /* @__PURE__ */ new Map();
15829
+ /** Total calls this session */
15830
+ lifetimeCount = 0;
15831
+ /** Hard stop flag — set when lifetime limit is reached */
15832
+ hardStopped = false;
15833
+ constructor(config) {
15834
+ this.config = { ...DEFAULT_CONFIG, ...config };
15835
+ }
15836
+ /**
15837
+ * Check if a call is allowed and apply governance.
15838
+ *
15839
+ * Evaluation order:
15840
+ * 1. Lifetime limit (hard stop — not recoverable without reset)
15841
+ * 2. Volume limit (sliding window)
15842
+ * 3. Rate limit per tool (escalates to Tier 2)
15843
+ * 4. Duplicate detection (returns cached result)
15844
+ */
15845
+ check(serverName, toolName, args) {
15846
+ const now = Date.now();
15847
+ const effectiveConfig = this.getEffectiveConfig(serverName);
15848
+ if (this.hardStopped) {
15849
+ return {
15850
+ allowed: false,
15851
+ reason: "lifetime_exceeded"
15852
+ };
15853
+ }
15854
+ if (this.lifetimeCount >= effectiveConfig.lifetime_limit) {
15855
+ this.hardStopped = true;
15856
+ return {
15857
+ allowed: false,
15858
+ reason: "lifetime_exceeded"
15859
+ };
15860
+ }
15861
+ this.pruneVolumeWindow(now, effectiveConfig.volume_window_ms);
15862
+ if (this.volumeWindow.length >= effectiveConfig.volume_limit) {
15863
+ return {
15864
+ allowed: false,
15865
+ reason: "volume_exceeded"
15866
+ };
15867
+ }
15868
+ const toolKey = `${serverName}::${toolName}`;
15869
+ this.pruneRateWindow(toolKey, now, effectiveConfig.rate_window_ms);
15870
+ const rateWindow = this.rateWindows.get(toolKey);
15871
+ const currentRate = rateWindow ? rateWindow.length : 0;
15872
+ if (currentRate >= effectiveConfig.rate_limit) {
15873
+ return {
15874
+ allowed: false,
15875
+ reason: "rate_exceeded",
15876
+ escalate: true
15877
+ };
15878
+ }
15879
+ const callHash = this.computeCallHash(serverName, toolName, args);
15880
+ this.pruneDuplicateCache(now);
15881
+ const cached = this.duplicateCache.get(callHash);
15882
+ if (cached && cached.expires_at > now) {
15883
+ return {
15884
+ allowed: true,
15885
+ reason: "duplicate_cached",
15886
+ cached_result: cached.result
15887
+ };
15888
+ }
15889
+ this.volumeWindow.push(now);
15890
+ if (!this.rateWindows.has(toolKey)) {
15891
+ this.rateWindows.set(toolKey, []);
15892
+ }
15893
+ this.rateWindows.get(toolKey).push(now);
15894
+ this.lifetimeCount++;
15895
+ return { allowed: true };
15896
+ }
15897
+ /**
15898
+ * Record a successful call result for duplicate caching.
15899
+ */
15900
+ recordResult(serverName, toolName, args, result) {
15901
+ const callHash = this.computeCallHash(serverName, toolName, args);
15902
+ const effectiveConfig = this.getEffectiveConfig(serverName);
15903
+ this.duplicateCache.set(callHash, {
15904
+ result,
15905
+ expires_at: Date.now() + effectiveConfig.duplicate_ttl_ms
15906
+ });
15907
+ }
15908
+ /**
15909
+ * Get current governor status for dashboard display.
15910
+ */
15911
+ getStatus() {
15912
+ const now = Date.now();
15913
+ this.pruneVolumeWindow(now, this.config.volume_window_ms);
15914
+ this.pruneDuplicateCache(now);
15915
+ const rateByTool = {};
15916
+ for (const [toolKey, timestamps] of this.rateWindows.entries()) {
15917
+ const cutoff = now - this.config.rate_window_ms;
15918
+ const activeCount = timestamps.filter((t) => t >= cutoff).length;
15919
+ if (activeCount > 0) {
15920
+ rateByTool[toolKey] = activeCount;
15921
+ }
15922
+ }
15923
+ return {
15924
+ volume_current: this.volumeWindow.length,
15925
+ volume_limit: this.config.volume_limit,
15926
+ lifetime_current: this.lifetimeCount,
15927
+ lifetime_limit: this.config.lifetime_limit,
15928
+ rate_by_tool: rateByTool,
15929
+ duplicate_cache_size: this.duplicateCache.size,
15930
+ hard_stopped: this.hardStopped
15931
+ };
15932
+ }
15933
+ /**
15934
+ * Reset all counters (Tier 1 — requires approval).
15935
+ * Clears volume window, rate windows, duplicate cache,
15936
+ * lifetime counter, and hard stop flag.
15937
+ */
15938
+ reset() {
15939
+ this.volumeWindow = [];
15940
+ this.rateWindows.clear();
15941
+ this.duplicateCache.clear();
15942
+ this.lifetimeCount = 0;
15943
+ this.hardStopped = false;
15944
+ }
15945
+ /**
15946
+ * Get the effective config for a given server, merging per-server overrides.
15947
+ */
15948
+ getEffectiveConfig(serverName) {
15949
+ const override = this.config.per_server_overrides?.[serverName];
15950
+ if (!override) return this.config;
15951
+ return { ...this.config, ...override };
15952
+ }
15953
+ /**
15954
+ * Compute a SHA-256 hash of server + tool + canonical args.
15955
+ * Used for duplicate detection.
15956
+ */
15957
+ computeCallHash(serverName, toolName, args) {
15958
+ const stableArgs = this.stableStringify(args);
15959
+ const input = `${serverName}\0${toolName}\0${stableArgs}`;
15960
+ return sha256Hex(input);
15961
+ }
15962
+ /**
15963
+ * Deterministic JSON serialization with sorted keys.
15964
+ */
15965
+ stableStringify(obj) {
15966
+ if (obj === null || obj === void 0) return "null";
15967
+ if (typeof obj !== "object") return JSON.stringify(obj);
15968
+ if (Array.isArray(obj)) {
15969
+ return "[" + obj.map((item) => this.stableStringify(item)).join(",") + "]";
15970
+ }
15971
+ const sortedKeys = Object.keys(obj).sort();
15972
+ const pairs = sortedKeys.map(
15973
+ (key) => JSON.stringify(key) + ":" + this.stableStringify(obj[key])
15974
+ );
15975
+ return "{" + pairs.join(",") + "}";
15976
+ }
15977
+ /**
15978
+ * Prune volume window entries older than the window size.
15979
+ * Uses shift() from the front — timestamps are appended in order.
15980
+ */
15981
+ pruneVolumeWindow(now, windowMs) {
15982
+ const cutoff = now - windowMs;
15983
+ while (this.volumeWindow.length > 0 && this.volumeWindow[0] < cutoff) {
15984
+ this.volumeWindow.shift();
15985
+ }
15986
+ }
15987
+ /**
15988
+ * Prune a per-tool rate window.
15989
+ */
15990
+ pruneRateWindow(toolKey, now, windowMs) {
15991
+ const window = this.rateWindows.get(toolKey);
15992
+ if (!window) return;
15993
+ const cutoff = now - windowMs;
15994
+ while (window.length > 0 && window[0] < cutoff) {
15995
+ window.shift();
15996
+ }
15997
+ if (window.length === 0) {
15998
+ this.rateWindows.delete(toolKey);
15999
+ }
16000
+ }
16001
+ /**
16002
+ * Prune expired entries from the duplicate cache.
16003
+ * Amortized O(1) — only prunes when cache exceeds a size threshold.
16004
+ */
16005
+ pruneDuplicateCache(now) {
16006
+ if (this.duplicateCache.size < 100) return;
16007
+ for (const [key, entry] of this.duplicateCache) {
16008
+ if (entry.expires_at <= now) {
16009
+ this.duplicateCache.delete(key);
16010
+ }
16011
+ }
16012
+ }
16013
+ };
16014
+
16015
+ // src/l2-operational/governor-tools.ts
16016
+ init_router();
16017
+ function createGovernorTools(governor, auditLog) {
16018
+ const tools = [
16019
+ // ── Governor Status ─────────────────────────────────────────────
16020
+ {
16021
+ name: "sanctuary/governor_status",
16022
+ description: "View the current Call Governor status including volume counters, per-tool rate counts, duplicate cache size, and lifetime counter. Use this to monitor tool call consumption, detect potential loops, and check how close you are to governance limits. The governor protects against runaway tool calls by enforcing volume limits, rate limits, duplicate detection, and a session lifetime cap.",
16023
+ inputSchema: {
16024
+ type: "object",
16025
+ properties: {}
16026
+ },
16027
+ handler: async () => {
16028
+ const status = governor.getStatus();
16029
+ auditLog.append("l2", "governor_status", "system", {
16030
+ volume_current: status.volume_current,
16031
+ volume_limit: status.volume_limit,
16032
+ lifetime_current: status.lifetime_current,
16033
+ lifetime_limit: status.lifetime_limit,
16034
+ duplicate_cache_size: status.duplicate_cache_size,
16035
+ hard_stopped: status.hard_stopped
16036
+ });
16037
+ const volumePercent = status.volume_limit > 0 ? Math.round(status.volume_current / status.volume_limit * 100) : 0;
16038
+ const lifetimePercent = status.lifetime_limit > 0 ? Math.round(status.lifetime_current / status.lifetime_limit * 100) : 0;
16039
+ const toolRateEntries = Object.entries(status.rate_by_tool);
16040
+ const highRateTools = toolRateEntries.filter(([, count]) => count > 10).map(([tool, count]) => `${tool} (${count} calls/min)`);
16041
+ return toolResult({
16042
+ governor_status: status,
16043
+ summary: {
16044
+ volume_usage: `${status.volume_current}/${status.volume_limit} (${volumePercent}%)`,
16045
+ lifetime_usage: `${status.lifetime_current}/${status.lifetime_limit} (${lifetimePercent}%)`,
16046
+ active_tools: toolRateEntries.length,
16047
+ cached_duplicates: status.duplicate_cache_size
16048
+ },
16049
+ warnings: [
16050
+ ...status.hard_stopped ? ["HARD STOP: Lifetime limit reached. Use sanctuary/governor_reset to continue."] : [],
16051
+ ...volumePercent > 80 ? [`Volume usage at ${volumePercent}% \u2014 approaching limit.`] : [],
16052
+ ...lifetimePercent > 80 ? [`Lifetime usage at ${lifetimePercent}% \u2014 approaching hard stop.`] : [],
16053
+ ...highRateTools.length > 0 ? [`High-rate tools detected: ${highRateTools.join(", ")}`] : []
16054
+ ],
16055
+ guidance: status.hard_stopped ? "The governor has stopped all proxied tool calls because the session lifetime limit was reached. Use sanctuary/governor_reset (requires human approval) to reset counters and resume." : "The governor is monitoring all proxied tool calls. Counters reset automatically on server restart."
16056
+ });
16057
+ }
16058
+ },
16059
+ // ── Governor Reset ──────────────────────────────────────────────
16060
+ {
16061
+ name: "sanctuary/governor_reset",
16062
+ description: "Reset all Call Governor counters: volume window, per-tool rate windows, duplicate cache, and lifetime counter. This clears the hard stop if the lifetime limit was reached. This is a Tier 1 operation \u2014 requires human approval because it removes all runtime governance state and could allow previously blocked behavior to resume.",
16063
+ inputSchema: {
16064
+ type: "object",
16065
+ properties: {
16066
+ confirm: {
16067
+ type: "boolean",
16068
+ description: "Must be true to confirm the reset. This clears all governor state including the lifetime counter."
16069
+ }
16070
+ },
16071
+ required: ["confirm"]
16072
+ },
16073
+ handler: async (args) => {
16074
+ const confirm = args.confirm;
16075
+ if (!confirm) {
16076
+ return toolResult({
16077
+ error: "confirmation_required",
16078
+ message: "Set confirm: true to reset all governor counters. This will clear volume limits, rate tracking, duplicate cache, and the lifetime counter."
16079
+ });
16080
+ }
16081
+ const preResetStatus = governor.getStatus();
16082
+ governor.reset();
16083
+ const postResetStatus = governor.getStatus();
16084
+ auditLog.append("l2", "governor_reset", "system", {
16085
+ pre_reset: {
16086
+ volume_current: preResetStatus.volume_current,
16087
+ lifetime_current: preResetStatus.lifetime_current,
16088
+ duplicate_cache_size: preResetStatus.duplicate_cache_size,
16089
+ hard_stopped: preResetStatus.hard_stopped
16090
+ },
16091
+ post_reset: {
16092
+ volume_current: postResetStatus.volume_current,
16093
+ lifetime_current: postResetStatus.lifetime_current,
16094
+ duplicate_cache_size: postResetStatus.duplicate_cache_size,
16095
+ hard_stopped: postResetStatus.hard_stopped
16096
+ }
16097
+ });
16098
+ return toolResult({
16099
+ reset: true,
16100
+ previous_state: {
16101
+ lifetime_calls: preResetStatus.lifetime_current,
16102
+ was_hard_stopped: preResetStatus.hard_stopped,
16103
+ volume_at_reset: preResetStatus.volume_current,
16104
+ cached_duplicates_cleared: preResetStatus.duplicate_cache_size
16105
+ },
16106
+ current_state: postResetStatus,
16107
+ message: "All governor counters have been reset. " + (preResetStatus.hard_stopped ? "Hard stop has been cleared \u2014 proxied tool calls will resume." : "Volume, rate, duplicate, and lifetime counters are now at zero.")
16108
+ });
16109
+ }
16110
+ }
16111
+ ];
16112
+ return { tools };
16113
+ }
16114
+
16115
+ // src/index.ts
16116
+ init_key_derivation();
16117
+ init_random();
16118
+ init_encoding();
16119
+ init_config();
16120
+ init_audit_log();
16121
+ init_sovereignty_profile();
16122
+ init_system_prompt_generator();
16123
+ init_filesystem();
16124
+ init_baseline();
16125
+ init_loader();
16126
+ init_dashboard();
16127
+ init_generator();
16128
+ async function createSanctuaryServer(options) {
16129
+ const config = await loadConfig(options?.configPath);
16130
+ await promises.mkdir(config.storage_path, { recursive: true, mode: 448 });
16131
+ const storage = options?.storage ?? new FilesystemStorage(
16132
+ `${config.storage_path}/state`
16133
+ );
16134
+ let masterKey;
16135
+ let keyProtection;
16136
+ let recoveryKey;
16137
+ const passphrase = options?.passphrase ?? process.env.SANCTUARY_PASSPHRASE;
16138
+ if (passphrase) {
16139
+ keyProtection = "passphrase";
16140
+ let existingParams;
16141
+ try {
16142
+ const raw = await storage.read("_meta", "key-params");
16143
+ if (raw) {
16144
+ const { bytesToString: bytesToString2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
16145
+ existingParams = JSON.parse(bytesToString2(raw));
16146
+ }
16147
+ } catch {
16148
+ }
16149
+ const result = await deriveMasterKey(passphrase, existingParams);
13672
16150
  masterKey = result.key;
13673
16151
  if (!existingParams) {
13674
16152
  const { stringToBytes: stringToBytes2 } = await Promise.resolve().then(() => (init_encoding(), encoding_exports));
@@ -14114,18 +16592,89 @@ async function createSanctuaryServer(options) {
14114
16592
  ...dashboardTools,
14115
16593
  manifestTool
14116
16594
  ];
16595
+ let clientManager;
16596
+ let proxyRouter;
16597
+ const governor = new CallGovernor();
16598
+ const { tools: governorTools } = createGovernorTools(governor, auditLog);
16599
+ allTools.push(...governorTools);
16600
+ const profile = profileStore.get();
16601
+ if (profile.upstream_servers && profile.upstream_servers.length > 0) {
16602
+ const enabledServers = profile.upstream_servers.filter((s) => s.enabled);
16603
+ if (enabledServers.length > 0) {
16604
+ clientManager = new ClientManager({
16605
+ onStateChange: (serverName, state, toolCount, error) => {
16606
+ if (dashboard) {
16607
+ dashboard.broadcastSSE("proxy-server-status", {
16608
+ server: serverName,
16609
+ state,
16610
+ tool_count: toolCount,
16611
+ error,
16612
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
16613
+ });
16614
+ }
16615
+ auditLog.append("l2", `proxy_server_${state}`, "system", {
16616
+ server: serverName,
16617
+ tool_count: toolCount,
16618
+ error
16619
+ });
16620
+ }
16621
+ });
16622
+ proxyRouter = new ProxyRouter(
16623
+ clientManager,
16624
+ injectionDetector,
16625
+ auditLog,
16626
+ {
16627
+ contextGateFilter: async (_toolName, args) => {
16628
+ const activeProfile = profileStore.get();
16629
+ if (activeProfile.features.context_gating.enabled) {
16630
+ return args;
16631
+ }
16632
+ return args;
16633
+ },
16634
+ governor
16635
+ }
16636
+ );
16637
+ clientManager.configure(enabledServers).catch((err) => {
16638
+ console.error(`[Sanctuary] Failed to configure upstream servers: ${err instanceof Error ? err.message : "unknown error"}`);
16639
+ });
16640
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
16641
+ const proxiedTools = proxyRouter.getProxiedTools();
16642
+ if (proxiedTools.length > 0) {
16643
+ allTools.push(...proxiedTools);
16644
+ }
16645
+ if (dashboard) {
16646
+ dashboard.setDependencies({
16647
+ policy,
16648
+ baseline,
16649
+ auditLog,
16650
+ clientManager
16651
+ });
16652
+ }
16653
+ }
16654
+ }
14117
16655
  allTools = allTools.map((tool) => ({
14118
16656
  ...tool,
14119
16657
  handler: contextGateEnforcer.wrapHandler(tool.name, tool.handler)
14120
16658
  }));
16659
+ if (proxyRouter) {
16660
+ gate.setProxyTierResolver((toolName) => {
16661
+ const parsed = ProxyRouter.parseProxyToolName(toolName);
16662
+ if (!parsed) return null;
16663
+ return proxyRouter.getTierForTool(parsed.serverName, parsed.toolName);
16664
+ });
16665
+ }
14121
16666
  const server = createServer(allTools, { gate });
14122
16667
  await saveConfig(config);
14123
- const saveBaseline = () => {
16668
+ const cleanup = () => {
14124
16669
  baseline.save().catch(() => {
14125
16670
  });
16671
+ if (clientManager) {
16672
+ clientManager.shutdown().catch(() => {
16673
+ });
16674
+ }
14126
16675
  };
14127
- process.on("SIGINT", saveBaseline);
14128
- process.on("SIGTERM", saveBaseline);
16676
+ process.on("SIGINT", cleanup);
16677
+ process.on("SIGTERM", cleanup);
14129
16678
  if (recoveryKey) {
14130
16679
  console.error(
14131
16680
  `\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557