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