@contextfort-ai/openclaw-secure 0.1.11 → 0.1.12

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.
@@ -48,7 +48,7 @@
48
48
  .card-sub { font-size: 12px; color: #a1a1aa; margin-top: 4px; }
49
49
 
50
50
  /* Guard summary cards on home */
51
- .guard-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
51
+ .guard-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
52
52
  .guard-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; background: rgba(255,255,255,0.02); cursor: pointer; transition: all 0.15s; }
53
53
  .guard-card:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.2); }
54
54
  .guard-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -157,7 +157,7 @@
157
157
 
158
158
  <button class="sidebar-btn" data-page="skill" onclick="switchPage('skill')">
159
159
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
160
- Skill Scanner
160
+ Skill &amp; Plugin Scanner
161
161
  <span class="dot dot-green" id="sb-skill-dot" style="margin-left:auto"></span>
162
162
  </button>
163
163
  <button class="sidebar-btn" data-page="bash" onclick="switchPage('bash')">
@@ -180,6 +180,11 @@
180
180
  Secrets Exfil Monitor
181
181
  <span class="dot dot-green" id="sb-exfil-dot" style="margin-left:auto"></span>
182
182
  </button>
183
+ <button class="sidebar-btn" data-page="sandbox" onclick="switchPage('sandbox')">
184
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M12 2v20"/><path d="M2 12h20"/></svg>
185
+ Plugin Sandbox
186
+ <span class="dot dot-green" id="sb-sandbox-dot" style="margin-left:auto"></span>
187
+ </button>
183
188
  </div>
184
189
  </aside>
185
190
 
@@ -235,7 +240,7 @@
235
240
  <div class="guard-card" onclick="switchPage('skill')">
236
241
  <div class="guard-card-header">
237
242
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
238
- <span>Skill Scanner</span>
243
+ <span>Skill &amp; Plugin Scanner</span>
239
244
  <span class="dot dot-green" id="g-skill-dot" style="margin-left:auto"></span>
240
245
  </div>
241
246
  <div class="guard-stat"><b id="g-skill-blocks">0</b> blocks</div>
@@ -272,6 +277,14 @@
272
277
  </div>
273
278
  <div class="guard-stat"><b id="g-exfil-detections">0</b> detections</div>
274
279
  </div>
280
+ <div class="guard-card" onclick="switchPage('sandbox')">
281
+ <div class="guard-card-header">
282
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="2"/><path d="M12 2v20"/><path d="M2 12h20"/></svg>
283
+ <span>Plugin Sandbox</span>
284
+ <span class="dot dot-green" id="g-sandbox-dot" style="margin-left:auto"></span>
285
+ </div>
286
+ <div class="guard-stat"><b id="g-sandbox-scrubs">0</b> env scrubs &middot; <b id="g-sandbox-fs">0</b> FS blocks &middot; <b id="g-sandbox-net">0</b> net logs</div>
287
+ </div>
275
288
  </div>
276
289
 
277
290
  <!-- Secret Scanner Summary -->
@@ -348,8 +361,8 @@
348
361
  <!-- SKILL SCANNER PAGE -->
349
362
  <div id="page-skill" style="display:none">
350
363
  <div class="page-header">
351
- <h1>Skill Scanner</h1>
352
- <p>Skills cross-indexed and scanned for prompt injection patterns via Haiku.</p>
364
+ <h1>Skill &amp; Plugin Scanner</h1>
365
+ <p>Skills and plugin code cross-indexed and scanned for prompt injection patterns via Haiku.</p>
353
366
  </div>
354
367
 
355
368
  <!-- Skills Status (top section) -->
@@ -480,6 +493,36 @@
480
493
  </div>
481
494
  </div>
482
495
 
496
+ <!-- PLUGIN SANDBOX PAGE -->
497
+ <div id="page-sandbox" style="display:none">
498
+ <div class="page-header">
499
+ <h1>Plugin Sandbox</h1>
500
+ <p>Runtime isolation for MCP plugin processes: env scrubbing, FS blocklist, and network logging.</p>
501
+ </div>
502
+
503
+ <div class="cards" style="margin-bottom:24px">
504
+ <div class="card"><div class="card-label">Env Scrubs</div><div class="card-value" id="sandbox-scrubs-count" style="color:#60a5fa">0</div><div class="card-sub">plugin spawns with env stripped</div></div>
505
+ <div class="card"><div class="card-label">FS Blocks</div><div class="card-value" id="sandbox-fs-count" style="color:#f87171">0</div><div class="card-sub">blocked reads to sensitive dirs</div></div>
506
+ <div class="card"><div class="card-label">Network Requests</div><div class="card-value" id="sandbox-net-count">0</div><div class="card-sub">outbound connections logged</div></div>
507
+ <div class="card"><div class="card-label">Sandbox Active</div><div class="card-value" id="sandbox-active-status" style="font-size:16px;color:#4ade80">Ready</div><div class="card-sub">activates on plugin spawn</div></div>
508
+ </div>
509
+
510
+ <div class="table-wrap">
511
+ <div class="table-header">
512
+ <h3>Events</h3>
513
+ <div class="filter-row">
514
+ <select id="sandbox-filter" onchange="renderSandboxPage()">
515
+ <option value="all">All events</option>
516
+ <option value="env_scrubbed">Env Scrubs</option>
517
+ <option value="fs_blocked">FS Blocks</option>
518
+ <option value="network_logged">Network Requests</option>
519
+ </select>
520
+ </div>
521
+ </div>
522
+ <div class="table-body" id="sandbox-table-body"></div>
523
+ </div>
524
+ </div>
525
+
483
526
  </main>
484
527
  </div>
485
528
 
@@ -525,7 +568,7 @@ function setDays(d) {
525
568
  function switchPage(page) {
526
569
  currentPage = page;
527
570
  document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.toggle('active', b.dataset.page === page));
528
- ['home','scan','skill','bash','pi','secrets','exfil'].forEach(p => {
571
+ ['home','scan','skill','bash','pi','secrets','exfil','sandbox'].forEach(p => {
529
572
  document.getElementById('page-' + p).style.display = p === page ? '' : 'none';
530
573
  });
531
574
  if (page === 'scan') loadScanStatus();
@@ -533,6 +576,7 @@ function switchPage(page) {
533
576
  else if (page === 'pi') renderPiPage();
534
577
  else if (page === 'secrets') renderSecretsPage();
535
578
  else if (page === 'exfil') { loadExfilAllowlist(); renderGuardPage('exfil'); }
579
+ else if (page === 'sandbox') renderSandboxPage();
536
580
  else if (page !== 'home') renderGuardPage(page);
537
581
  }
538
582
 
@@ -555,6 +599,7 @@ async function refresh() {
555
599
  else if (currentPage === 'pi') renderPiPage();
556
600
  else if (currentPage === 'secrets') renderSecretsPage();
557
601
  else if (currentPage === 'exfil') { loadExfilAllowlist(); renderGuardPage('exfil'); }
602
+ else if (currentPage === 'sandbox') renderSandboxPage();
558
603
  else if (currentPage !== 'home') renderGuardPage(currentPage);
559
604
  loadScanStatus(); // always update home scan summary
560
605
  document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
@@ -577,6 +622,9 @@ function renderOverview(ov) {
577
622
  document.getElementById('g-sec-blocks').textContent = gs.secrets_guard?.blocks || 0;
578
623
  document.getElementById('g-sec-redactions').textContent = gs.secrets_guard?.redactions || 0;
579
624
  document.getElementById('g-exfil-detections').textContent = gs.exfil_monitor?.detections || 0;
625
+ document.getElementById('g-sandbox-scrubs').textContent = gs.plugin_sandbox?.scrubs || 0;
626
+ document.getElementById('g-sandbox-fs').textContent = gs.plugin_sandbox?.fs_blocks || 0;
627
+ document.getElementById('g-sandbox-net').textContent = gs.plugin_sandbox?.net_logs || 0;
580
628
 
581
629
  const hasData = ov.total > 0;
582
630
  setDot('g-skill-dot', hasData, gs.skill_scanner?.blocks > 0);
@@ -584,6 +632,7 @@ function renderOverview(ov) {
584
632
  setDot('g-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
585
633
  setDot('g-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
586
634
  setDot('g-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
635
+ setDot('g-sandbox-dot', hasData, (gs.plugin_sandbox?.fs_blocks > 0), 'dot-yellow');
587
636
  }
588
637
 
589
638
  function renderSidebarDots(ov) {
@@ -594,6 +643,7 @@ function renderSidebarDots(ov) {
594
643
  setDot('sb-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
595
644
  setDot('sb-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
596
645
  setDot('sb-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
646
+ setDot('sb-sandbox-dot', hasData, (gs.plugin_sandbox?.fs_blocks > 0), 'dot-yellow');
597
647
  }
598
648
 
599
649
  function setDot(id, hasData, hasBlocks, activeClass) {
@@ -629,7 +679,7 @@ function renderBlockBanner() {
629
679
  }
630
680
 
631
681
  banner.style.display = '';
632
- const guard = blockEvent.guard === 'skill' ? 'Skill Scanner' : 'Prompt Injection';
682
+ const guard = blockEvent.guard === 'skill' ? 'Skill & Plugin Scanner' : 'Prompt Injection';
633
683
  const guardPage = blockEvent.guard === 'skill' ? 'skill' : 'pi';
634
684
  document.getElementById('home-block-title').textContent = `Agent Blocked — ${guard}`;
635
685
  document.getElementById('home-block-reason').textContent = blockEvent.reason || 'A security threat was detected. All commands are blocked.';
@@ -716,14 +766,16 @@ function renderSkillStatus() {
716
766
  if (!sp) continue;
717
767
  const name = e.detail?.skill_name || sp.split('/').pop();
718
768
  if (!skillMap.has(sp)) {
719
- skillMap.set(sp, { name, path: sp, dir: e.detail?.skill_dir || '', status: 'clean', reason: '', fileCount: 0, fileNames: [], binaryFiles: [], fileContents: [], ts: e.ts, decision: e.decision });
769
+ skillMap.set(sp, { name, path: sp, dir: e.detail?.skill_dir || '', status: 'clean', reason: '', fileCount: 0, fileNames: [], binaryFiles: [], skippedFiles: [], fileContents: [], ts: e.ts, decision: e.decision, type: e.detail?.type || 'skill' });
720
770
  }
721
771
  const skill = skillMap.get(sp);
722
772
  skill.ts = e.ts; // update to latest timestamp
723
773
  skill.decision = e.decision;
774
+ if (e.detail?.type) skill.type = e.detail.type;
724
775
  if (e.detail?.file_count !== undefined) skill.fileCount = e.detail.file_count;
725
776
  if (e.detail?.file_names) skill.fileNames = e.detail.file_names;
726
777
  if (e.detail?.binary_files) skill.binaryFiles = e.detail.binary_files;
778
+ if (e.detail?.skipped_files) skill.skippedFiles = e.detail.skipped_files;
727
779
  if (e.detail?.file_contents) skill.fileContents = e.detail.file_contents;
728
780
  // Determine status
729
781
  if (e.decision === 'scan_flagged') { skill.status = 'malicious'; skill.reason = e.reason || ''; }
@@ -741,13 +793,14 @@ function renderSkillStatus() {
741
793
  const skills = Array.from(skillMap.values()).filter(s => s.status !== 'removed');
742
794
 
743
795
  if (skills.length === 0) {
744
- container.innerHTML = '<div class="empty">No skills discovered yet. Skills appear when openclaw-secure is active.</div>';
796
+ container.innerHTML = '<div class="empty">No skills or plugins discovered yet. They appear when openclaw-secure is active.</div>';
745
797
  return;
746
798
  }
747
799
 
748
800
  container.innerHTML = `<table><thead><tr>
749
801
  <th style="width:40px"></th>
750
- <th>Skill</th>
802
+ <th>Name</th>
803
+ <th>Type</th>
751
804
  <th>Path</th>
752
805
  <th>Status</th>
753
806
  <th>Reason</th>
@@ -762,19 +815,26 @@ function renderSkillStatus() {
762
815
  : s.status === 'scanning'
763
816
  ? '<span class="badge badge-blue">Scanning</span>'
764
817
  : '<span class="badge badge-green">Clean</span>';
818
+ const typeBadge = s.type === 'plugin'
819
+ ? '<span class="badge badge-blue">Plugin</span>'
820
+ : '<span class="badge" style="background:rgba(255,255,255,0.06);color:#a1a1aa">Skill</span>';
765
821
  const deleteBtn = (s.status === 'malicious' || s.status === 'binary')
766
822
  ? `<button class="btn btn-sm" style="color:#f87171;border-color:rgba(239,68,68,0.3)" onclick="deleteSkill('${esc(s.path.replace(/'/g, "\\'"))}')">Delete</button>`
767
823
  : '';
768
824
  const viewFiles = s.fileNames.length > 0 || s.fileContents.length > 0
769
825
  ? `<span class="payload-toggle" onclick="openSkillFiles(${i})">${s.fileCount || s.fileNames.length}</span>`
770
826
  : `<span style="color:#a1a1aa">${s.fileCount || 0}</span>`;
827
+ const skippedWarning = s.skippedFiles && s.skippedFiles.length > 0
828
+ ? `<span title="${esc(s.skippedFiles.map(f => f.file + ': ' + f.reason).join('\n'))}" style="color:#eab308;cursor:help;margin-left:4px">&#9888; ${s.skippedFiles.length} skipped</span>`
829
+ : '';
771
830
  return `<tr>
772
831
  <td>${deleteBtn}</td>
773
832
  <td style="font-weight:500;font-family:ui-monospace,monospace;font-size:13px">${esc(s.name)}</td>
833
+ <td>${typeBadge}</td>
774
834
  <td style="font-size:12px;color:#a1a1aa;max-width:250px;word-break:break-all">${esc(s.path)}</td>
775
835
  <td>${statusBadge}</td>
776
836
  <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(s.reason)}</td>
777
- <td style="text-align:center">${viewFiles}</td>
837
+ <td style="text-align:center">${viewFiles}${skippedWarning}</td>
778
838
  <td style="white-space:nowrap;color:#a1a1aa;font-size:12px">${formatTime(s.ts)}</td>
779
839
  </tr>`;
780
840
  }).join('') + '</tbody></table>';
@@ -855,7 +915,7 @@ function renderSkillActivityLog() {
855
915
  }).join('') + '</tbody></table>';
856
916
  } else {
857
917
  // Show all skill-specific events (not "allow" per-command checks)
858
- const skillDecisions = new Set(['init', 'init_skill', 'scanning', 'scan_clean', 'scan_flagged', 'binary_detected', 'removed', 'modified', 'deleted']);
918
+ const skillDecisions = new Set(['init', 'init_skill', 'scanning', 'scan_clean', 'scan_flagged', 'binary_detected', 'files_skipped', 'removed', 'modified', 'deleted']);
859
919
  let filtered = localEvents.filter(e => e.guard === 'skill' && skillDecisions.has(e.decision));
860
920
  if (blockedOnly) {
861
921
  filtered = filtered.filter(e => e.decision === 'scan_flagged' || e.decision === 'binary_detected');
@@ -867,18 +927,23 @@ function renderSkillActivityLog() {
867
927
  container.innerHTML = `<table><thead><tr>
868
928
  <th>Time</th>
869
929
  <th>Event</th>
870
- <th>Skill</th>
930
+ <th>Name</th>
931
+ <th>Type</th>
871
932
  <th>Path</th>
872
933
  <th>Files</th>
873
934
  <th>Detail</th>
874
935
  </tr></thead><tbody>` +
875
936
  filtered.map((e, i) => {
876
- const badgeMap = { init: 'badge-gray', init_skill: 'badge-gray', scanning: 'badge-blue', scan_clean: 'badge-green', scan_flagged: 'badge-red', binary_detected: 'badge-red', removed: 'badge-yellow', modified: 'badge-blue', deleted: 'badge-yellow' };
877
- const labelMap = { init: 'Init', init_skill: 'Discovered', scanning: 'Scanning', scan_clean: 'Clean', scan_flagged: 'Flagged', binary_detected: 'Binary', removed: 'Removed', modified: 'Modified', deleted: 'Deleted' };
937
+ const badgeMap = { init: 'badge-gray', init_skill: 'badge-gray', scanning: 'badge-blue', scan_clean: 'badge-green', scan_flagged: 'badge-red', binary_detected: 'badge-red', files_skipped: 'badge-yellow', removed: 'badge-yellow', modified: 'badge-blue', deleted: 'badge-yellow' };
938
+ const labelMap = { init: 'Init', init_skill: 'Discovered', scanning: 'Scanning', scan_clean: 'Clean', scan_flagged: 'Flagged', binary_detected: 'Binary', files_skipped: 'Skipped', removed: 'Removed', modified: 'Modified', deleted: 'Deleted' };
878
939
  const badge = badgeMap[e.decision] || 'badge-gray';
879
940
  const label = labelMap[e.decision] || e.decision;
880
941
  const skillName = e.detail?.skill_name || '-';
881
942
  const skillPath = e.detail?.skill_path || '';
943
+ const entryType = e.detail?.type || 'skill';
944
+ const typeBadge = entryType === 'plugin'
945
+ ? '<span class="badge badge-blue">Plugin</span>'
946
+ : '<span class="badge" style="background:rgba(255,255,255,0.06);color:#a1a1aa">Skill</span>';
882
947
  const fileCount = e.detail?.file_count ?? e.detail?.file_names?.length ?? '';
883
948
  const fileNames = e.detail?.file_names || [];
884
949
  const binaryFiles = e.detail?.binary_files || [];
@@ -891,6 +956,8 @@ function renderSkillActivityLog() {
891
956
  const detailObj = {};
892
957
  if (fileNames.length > 0) detailObj.files = fileNames;
893
958
  if (binaryFiles.length > 0) detailObj.binary_files = binaryFiles;
959
+ if (e.detail?.skipped_files?.length > 0) detailObj.skipped_files = e.detail.skipped_files;
960
+ if (e.detail?.total_size_bytes) detailObj.total_size = (e.detail.total_size_bytes / 1024).toFixed(1) + ' KB';
894
961
  if (e.detail?.directories) detailObj.directories = e.detail.directories;
895
962
  if (e.detail?.cached !== undefined) detailObj.cached = e.detail.cached;
896
963
  if (Object.keys(detailObj).length > 0) {
@@ -901,6 +968,7 @@ function renderSkillActivityLog() {
901
968
  <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
902
969
  <td><span class="badge ${badge}">${label}</span></td>
903
970
  <td style="font-family:ui-monospace,monospace;font-size:13px">${esc(skillName)}</td>
971
+ <td>${typeBadge}</td>
904
972
  <td style="font-size:12px;color:#a1a1aa;max-width:200px;word-break:break-all">${esc(skillPath)}</td>
905
973
  <td style="text-align:center;color:#a1a1aa">${fileCount}</td>
906
974
  <td style="font-size:12px;color:#a1a1aa;max-width:350px">${detailParts.join('') + detailExtra}</td>
@@ -1153,6 +1221,60 @@ async function toggleExfilAllowlist() {
1153
1221
  } catch (e) { alert('Failed: ' + e.message); }
1154
1222
  }
1155
1223
 
1224
+ // =============================================
1225
+ // Plugin Sandbox Page
1226
+ // =============================================
1227
+ function renderSandboxPage() {
1228
+ const filter = document.getElementById('sandbox-filter')?.value || 'all';
1229
+ const container = document.getElementById('sandbox-table-body');
1230
+
1231
+ const sandboxEvents = localEvents.filter(e => e.guard === 'sandbox');
1232
+ const scrubs = sandboxEvents.filter(e => e.decision === 'env_scrubbed');
1233
+ const fsBlocks = sandboxEvents.filter(e => e.decision === 'fs_blocked');
1234
+ const netLogs = sandboxEvents.filter(e => e.decision === 'network_logged');
1235
+
1236
+ // Update stat cards
1237
+ const el = (id) => document.getElementById(id);
1238
+ if (el('sandbox-scrubs-count')) el('sandbox-scrubs-count').textContent = scrubs.length;
1239
+ if (el('sandbox-fs-count')) el('sandbox-fs-count').textContent = fsBlocks.length;
1240
+ if (el('sandbox-net-count')) el('sandbox-net-count').textContent = netLogs.length;
1241
+
1242
+ let filtered = sandboxEvents;
1243
+ if (filter === 'env_scrubbed') filtered = scrubs;
1244
+ else if (filter === 'fs_blocked') filtered = fsBlocks;
1245
+ else if (filter === 'network_logged') filtered = netLogs;
1246
+
1247
+ if (filtered.length === 0) {
1248
+ container.innerHTML = '<div class="empty">No sandbox events yet. Events appear when plugin MCP servers are spawned.</div>';
1249
+ return;
1250
+ }
1251
+
1252
+ container.innerHTML = `<table><thead><tr>
1253
+ <th>Time</th>
1254
+ <th>Type</th>
1255
+ <th>Detail</th>
1256
+ <th>Extra</th>
1257
+ </tr></thead><tbody>` +
1258
+ filtered.map((e, i) => {
1259
+ const badgeMap = { env_scrubbed: 'badge-blue', fs_blocked: 'badge-red', network_logged: 'badge-yellow' };
1260
+ const labelMap = { env_scrubbed: 'Env Scrub', fs_blocked: 'FS Block', network_logged: 'Network' };
1261
+ const badge = badgeMap[e.decision] || 'badge-gray';
1262
+ const label = labelMap[e.decision] || e.decision;
1263
+
1264
+ let detail = esc(e.reason || '-');
1265
+ const uid = 'sandbox-lc-' + i;
1266
+ const detailObj = e.detail ? JSON.stringify(e.detail, null, 2) : '';
1267
+ const detailHtml = detailObj ? `<span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(detailObj)}</div>` : '';
1268
+
1269
+ return `<tr>
1270
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1271
+ <td><span class="badge ${badge}">${label}</span></td>
1272
+ <td style="font-size:12px;color:#a1a1aa;max-width:400px">${detail}</td>
1273
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${detailHtml}</td>
1274
+ </tr>`;
1275
+ }).join('') + '</tbody></table>';
1276
+ }
1277
+
1156
1278
  const SYSTEM_COMMANDS = /^(networksetup|arp|defaults)\b/;
1157
1279
 
1158
1280
  function renderGuardPage(guard) {
@@ -101,12 +101,17 @@ module.exports = function startDashboard({ port = 9009 } = {}) {
101
101
  const exfilDetections = events.filter(e => e.event === 'guard_check' && e.guard === 'exfil').length;
102
102
  const secretsLeaked = events.filter(e => e.event === 'guard_check' && e.guard === 'secrets_leak').length;
103
103
 
104
+ const sandboxScrubs = events.filter(e => e.guard === 'sandbox' && e.decision === 'env_scrubbed').length;
105
+ const sandboxFsBlocks = events.filter(e => e.guard === 'sandbox' && e.decision === 'fs_blocked').length;
106
+ const sandboxNetLogs = events.filter(e => e.guard === 'sandbox' && e.decision === 'network_logged').length;
107
+
104
108
  const guardStatus = {
105
109
  skill_scanner: { blocks: byGuard.skill || 0, active: true },
106
110
  bash_guard: { blocks: byGuard.tirith || 0, active: true },
107
111
  prompt_injection: { blocks: byGuard.prompt_injection || 0, active: true },
108
112
  secrets_guard: { blocks: (byGuard.env_var || 0), redactions: redacted, leaks: secretsLeaked, active: true },
109
113
  exfil_monitor: { detections: exfilDetections, active: true },
114
+ plugin_sandbox: { scrubs: sandboxScrubs, fs_blocks: sandboxFsBlocks, net_logs: sandboxNetLogs, active: true },
110
115
  };
111
116
 
112
117
  json(res, { total, blocked, allowed, redacted, byGuard, guardStatus, activeSince });
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ const HOME = os.homedir();
7
+ const PLUGIN_DIR = path.join(HOME, '.claude', 'plugins');
8
+
9
+ // Bare minimum for a Node.js process to function
10
+ const SANDBOX_KEEP_VARS = new Set([
11
+ 'PATH', 'HOME', 'USER', 'LOGNAME',
12
+ 'TMPDIR', 'TEMP', 'TMP',
13
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
14
+ 'SHELL', 'TERM',
15
+ 'NODE_OPTIONS', // so our hook loads in grandchild processes
16
+ 'NODE_PATH', // module resolution
17
+ '__CONTEXTFORT_SANDBOX', // our marker
18
+ ]);
19
+
20
+ const BLOCKED_DIRS = [
21
+ path.join(HOME, '.ssh'),
22
+ path.join(HOME, '.aws'),
23
+ path.join(HOME, '.gnupg'),
24
+ path.join(HOME, '.config'),
25
+ path.join(HOME, '.contextfort'),
26
+ path.join(HOME, '.npmrc'),
27
+ path.join(HOME, '.netrc'),
28
+ path.join(HOME, '.env'),
29
+ path.join(HOME, '.bash_history'),
30
+ path.join(HOME, '.zsh_history'),
31
+ path.join(HOME, '.gitconfig'),
32
+ ];
33
+
34
+ // Commands that can launch plugin code
35
+ const NODE_LIKE = new Set(['node', 'npx', 'tsx', 'bun']);
36
+
37
+ module.exports = function createPluginGuard({ localLogger, analytics }) {
38
+ const track = analytics ? analytics.track.bind(analytics) : () => {};
39
+
40
+ /**
41
+ * Returns true if we're running inside a sandboxed plugin process.
42
+ */
43
+ function isSandboxed() {
44
+ return !!process.env.__CONTEXTFORT_SANDBOX;
45
+ }
46
+
47
+ /**
48
+ * Returns true if the spawn target is a plugin under ~/.claude/plugins/
49
+ */
50
+ function isPluginSpawn(command, args) {
51
+ if (!command) return false;
52
+ const cmd = path.basename(command);
53
+
54
+ // Direct path under plugins dir
55
+ try {
56
+ const resolved = path.resolve(command);
57
+ if (resolved.startsWith(PLUGIN_DIR + path.sep)) return true;
58
+ } catch {}
59
+
60
+ // node/npx/tsx/bun launching a file under plugins dir
61
+ if (NODE_LIKE.has(cmd)) {
62
+ if (Array.isArray(args)) {
63
+ for (const arg of args) {
64
+ if (typeof arg !== 'string') continue;
65
+ if (arg.startsWith('-')) continue; // skip flags
66
+ try {
67
+ const resolved = path.resolve(arg);
68
+ if (resolved.startsWith(PLUGIN_DIR + path.sep)) return true;
69
+ } catch {}
70
+ }
71
+ }
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Returns a new env object with only SANDBOX_KEEP_VARS + the sandbox marker.
79
+ * Logs the stripped variable names.
80
+ */
81
+ function scrubEnvForPlugin(originalEnv) {
82
+ const scrubbed = {};
83
+ const stripped = [];
84
+
85
+ for (const key of Object.keys(originalEnv)) {
86
+ if (SANDBOX_KEEP_VARS.has(key)) {
87
+ scrubbed[key] = originalEnv[key];
88
+ } else {
89
+ stripped.push(key);
90
+ }
91
+ }
92
+
93
+ scrubbed.__CONTEXTFORT_SANDBOX = '1';
94
+
95
+ if (localLogger) {
96
+ try {
97
+ localLogger.logLocal({
98
+ event: 'guard_check',
99
+ guard: 'sandbox',
100
+ decision: 'env_scrubbed',
101
+ reason: `Scrubbed ${stripped.length} env vars from plugin process`,
102
+ detail: {
103
+ kept: Array.from(SANDBOX_KEEP_VARS),
104
+ stripped_count: stripped.length,
105
+ stripped_names: stripped,
106
+ },
107
+ });
108
+ } catch {}
109
+ }
110
+
111
+ track('sandbox_env_scrubbed', { stripped_count: stripped.length });
112
+ return scrubbed;
113
+ }
114
+
115
+ /**
116
+ * Checks if a file path is blocked under the sandbox.
117
+ * Returns null if allowed, { blocked: true, reason } if blocked.
118
+ * Only active when isSandboxed() is true.
119
+ */
120
+ function checkFsAccess(filePath) {
121
+ if (!isSandboxed()) return null;
122
+ if (!filePath || typeof filePath !== 'string') return null;
123
+
124
+ let resolved;
125
+ try {
126
+ resolved = path.resolve(String(filePath));
127
+ } catch {
128
+ return null;
129
+ }
130
+
131
+ for (const dir of BLOCKED_DIRS) {
132
+ if (resolved === dir || resolved.startsWith(dir + path.sep)) {
133
+ if (localLogger) {
134
+ try {
135
+ localLogger.logLocal({
136
+ event: 'guard_check',
137
+ guard: 'sandbox',
138
+ decision: 'fs_blocked',
139
+ reason: `Sandbox blocked read: ${resolved}`,
140
+ detail: { path: resolved, blocked_dir: dir },
141
+ });
142
+ } catch {}
143
+ }
144
+ track('sandbox_fs_blocked', { path: resolved, blocked_dir: dir });
145
+ return {
146
+ blocked: true,
147
+ reason: `SANDBOX: Access denied — ${resolved} is blocked for plugin processes`,
148
+ };
149
+ }
150
+ }
151
+
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Logs an outbound network request from a sandboxed plugin process.
157
+ * Only active when isSandboxed() is true.
158
+ */
159
+ function logNetworkRequest(details) {
160
+ if (!isSandboxed()) return;
161
+
162
+ if (localLogger) {
163
+ try {
164
+ localLogger.logLocal({
165
+ event: 'guard_check',
166
+ guard: 'sandbox',
167
+ decision: 'network_logged',
168
+ reason: `Plugin network request: ${details.method || 'GET'} ${details.host || ''}${details.path || ''}`,
169
+ detail: {
170
+ host: details.host,
171
+ port: details.port,
172
+ method: details.method,
173
+ path: details.path,
174
+ protocol: details.protocol,
175
+ },
176
+ });
177
+ } catch {}
178
+ }
179
+
180
+ track('sandbox_network', {
181
+ host: details.host,
182
+ port: details.port,
183
+ method: details.method,
184
+ });
185
+ }
186
+
187
+ return {
188
+ isSandboxed,
189
+ isPluginSpawn,
190
+ scrubEnvForPlugin,
191
+ checkFsAccess,
192
+ logNetworkRequest,
193
+ };
194
+ };
@@ -72,56 +72,89 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
72
72
  const entries = fs.readdirSync(dir, { withFileTypes: true });
73
73
  for (const e of entries) {
74
74
  if (e.isDirectory()) {
75
- skills.push({ skillPath: path.join(dir, e.name), skillName: e.name });
75
+ skills.push({ skillPath: path.join(dir, e.name), skillName: e.name, type: 'skill' });
76
76
  }
77
77
  }
78
78
  // If there are loose files directly in the skill dir (e.g. SKILL.md), treat as a skill
79
79
  const hasLooseFiles = entries.some(e => e.isFile());
80
80
  if (hasLooseFiles) {
81
- skills.push({ skillPath: dir, skillName: path.basename(dir) });
81
+ skills.push({ skillPath: dir, skillName: path.basename(dir), type: 'skill' });
82
82
  }
83
83
  } catch {}
84
84
  return skills;
85
85
  }
86
86
 
87
+ function getPluginEntries() {
88
+ const pluginsDir = path.join(HOME, '.claude', 'plugins');
89
+ const entries = [];
90
+ try {
91
+ const dirents = fs.readdirSync(pluginsDir, { withFileTypes: true });
92
+ for (const e of dirents) {
93
+ if (e.isDirectory() && !e.name.startsWith('.')) {
94
+ entries.push({
95
+ skillPath: path.join(pluginsDir, e.name),
96
+ skillName: e.name,
97
+ type: 'plugin',
98
+ });
99
+ }
100
+ }
101
+ } catch {}
102
+ return entries;
103
+ }
104
+
87
105
  function readSkillFiles(skillPath) {
88
106
  const files = [];
89
107
  const binaryFiles = [];
108
+ const skippedFiles = []; // { file, reason }
90
109
  const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
91
110
  const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
92
111
  let totalSize = 0;
112
+ let totalSizeExceeded = false;
93
113
 
94
114
  function walk(dirPath, base) {
95
115
  let entries;
96
116
  try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
97
117
  for (const e of entries) {
98
- if (e.name.startsWith('.') || e.name === 'node_modules') continue; // skip hidden and node_modules
118
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
99
119
  const full = path.join(dirPath, e.name);
120
+ const rel = path.join(base, e.name);
100
121
  if (e.isDirectory()) {
101
- walk(full, path.join(base, e.name));
122
+ walk(full, rel);
102
123
  } else if (e.isFile()) {
103
124
  try {
104
125
  const stat = fs.statSync(full);
105
- if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
106
- if (totalSize + stat.size > MAX_TOTAL_SIZE) continue;
126
+ if (stat.size === 0) {
127
+ skippedFiles.push({ file: rel, reason: 'empty file (0 bytes)' });
128
+ continue;
129
+ }
130
+ if (stat.size > MAX_FILE_SIZE) {
131
+ skippedFiles.push({ file: rel, reason: `exceeds 1MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB)` });
132
+ continue;
133
+ }
134
+ if (totalSize + stat.size > MAX_TOTAL_SIZE) {
135
+ if (!totalSizeExceeded) totalSizeExceeded = true;
136
+ skippedFiles.push({ file: rel, reason: `total size would exceed 5MB limit (already ${(totalSize / 1024 / 1024).toFixed(1)}MB)` });
137
+ continue;
138
+ }
107
139
  // Check for binaries: read first 512 bytes and check for null bytes
108
140
  const buf = Buffer.alloc(Math.min(512, stat.size));
109
141
  const fd = fs.openSync(full, 'r');
110
142
  try { fs.readSync(fd, buf, 0, buf.length, 0); } finally { fs.closeSync(fd); }
111
143
  if (buf.includes(0)) {
112
- binaryFiles.push(path.join(base, e.name));
144
+ binaryFiles.push(rel);
145
+ skippedFiles.push({ file: rel, reason: 'binary file (contains null bytes)' });
113
146
  continue;
114
147
  }
115
148
 
116
149
  const content = readFileSync(full, 'utf8');
117
150
  totalSize += stat.size;
118
- files.push({ relative_path: path.join(base, e.name), content });
151
+ files.push({ relative_path: rel, content });
119
152
  } catch {}
120
153
  }
121
154
  }
122
155
  }
123
156
  walk(skillPath, '');
124
- return { files, binaryFiles };
157
+ return { files, binaryFiles, skippedFiles, totalSize };
125
158
  }
126
159
 
127
160
  function hashSkillFiles(files) {
@@ -165,12 +198,12 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
165
198
  } catch {}
166
199
  }
167
200
 
168
- function scanSkillAsync(skillPath, files, hash, installId) {
201
+ function scanSkillAsync(skillPath, files, hash, installId, type, skippedFiles) {
169
202
  if (pendingScans.has(skillPath)) return;
170
203
  pendingScans.add(skillPath);
171
- track('skill_scan_started', { skill_name: path.basename(skillPath), file_count: files.length });
204
+ track('skill_scan_started', { skill_name: path.basename(skillPath), file_count: files.length, type: type || 'skill' });
172
205
  if (localLogger) {
173
- try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scanning', reason: `Scanning skill: ${path.basename(skillPath)} (${files.length} files)`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })) } }); } catch {}
206
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scanning', reason: `Scanning ${type || 'skill'}: ${path.basename(skillPath)} (${files.length} files)`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })), type: type || 'skill' } }); } catch {}
174
207
  }
175
208
 
176
209
  const payload = JSON.stringify({
@@ -178,6 +211,8 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
178
211
  skill_path: skillPath,
179
212
  skill_name: path.basename(skillPath),
180
213
  files: files,
214
+ type: type || 'skill',
215
+ skipped_files: skippedFiles && skippedFiles.length > 0 ? skippedFiles : undefined,
181
216
  });
182
217
 
183
218
  // Log what we're sending to Supabase (omit file contents for privacy)
@@ -217,13 +252,13 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
217
252
  } else {
218
253
  flaggedSkills.delete(skillPath);
219
254
  }
220
- track('skill_scan_result', { skill_name: path.basename(skillPath), suspicious: !!result.suspicious, status_code: 200 });
255
+ track('skill_scan_result', { skill_name: path.basename(skillPath), suspicious: !!result.suspicious, status_code: 200, type: type || 'skill' });
221
256
  if (localLogger) {
222
257
  try {
223
258
  if (result.suspicious) {
224
- localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_flagged', reason: result.reason || 'Suspicious skill detected', detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })) } });
259
+ localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_flagged', reason: result.reason || 'Suspicious skill detected', detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })), type: type || 'skill' } });
225
260
  } else {
226
- localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_clean', reason: `Skill scan clean: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path) } });
261
+ localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_clean', reason: `Scan clean: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), type: type || 'skill' } });
227
262
  }
228
263
  } catch {}
229
264
  }
@@ -244,10 +279,10 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
244
279
  }
245
280
  }
246
281
 
247
- function logSkillRemoved(skillPath, installId) {
248
- track('skill_removed', { skill_name: path.basename(skillPath) });
282
+ function logSkillRemoved(skillPath, installId, type) {
283
+ track('skill_removed', { skill_name: path.basename(skillPath), type: type || 'skill' });
249
284
  if (localLogger) {
250
- try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'removed', reason: `Skill removed: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath } }); } catch {}
285
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'removed', reason: `${(type || 'skill')} removed: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, type: type || 'skill' } }); } catch {}
251
286
  }
252
287
  const payload = JSON.stringify({
253
288
  install_id: installId,
@@ -255,6 +290,7 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
255
290
  skill_name: path.basename(skillPath),
256
291
  files: [],
257
292
  removed: true,
293
+ type: type || 'skill',
258
294
  });
259
295
 
260
296
  const url = new URL(SKILL_SCAN_API);
@@ -278,8 +314,8 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
278
314
  } catch {}
279
315
  }
280
316
 
281
- function scanSkillIfChanged(skillPath) {
282
- const { files, binaryFiles } = readSkillFiles(skillPath);
317
+ function scanSkillIfChanged(skillPath, type) {
318
+ const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
283
319
  if (files.length === 0 && binaryFiles.length === 0) {
284
320
  // Skill was deleted or is empty — remove from flagged
285
321
  flaggedSkills.delete(skillPath);
@@ -288,15 +324,20 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
288
324
  return;
289
325
  }
290
326
 
327
+ // Log skipped files if any
328
+ if (skippedFiles.length > 0 && localLogger) {
329
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'files_skipped', reason: `${skippedFiles.length} file(s) skipped in ${type || 'skill'}: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, type: type || 'skill', skipped_files: skippedFiles, total_size_bytes: totalSize, scanned_file_count: files.length } }); } catch {}
330
+ }
331
+
291
332
  // Flag immediately if binary files found — legitimate skills should not contain binaries
292
333
  if (binaryFiles.length > 0) {
293
334
  flaggedSkills.set(skillPath, {
294
335
  suspicious: true,
295
- reason: `Skill contains binary files (${binaryFiles.join(', ')}). Legitimate skills should only contain text files. Please delete these binary files or remove this skill.`,
336
+ reason: `${(type || 'skill')} contains binary files (${binaryFiles.join(', ')}). Please delete these binary files or remove this ${type || 'skill'}.`,
296
337
  });
297
- track('skill_binary_detected', { skill_name: path.basename(skillPath), binary_count: binaryFiles.length });
338
+ track('skill_binary_detected', { skill_name: path.basename(skillPath), binary_count: binaryFiles.length, type: type || 'skill' });
298
339
  if (localLogger) {
299
- try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'binary_detected', reason: `Binary files found in skill: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, binary_files: binaryFiles } }); } catch {}
340
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'binary_detected', reason: `Binary files found in ${type || 'skill'}: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, binary_files: binaryFiles, type: type || 'skill' } }); } catch {}
300
341
  }
301
342
  saveScanCache();
302
343
  return;
@@ -307,14 +348,14 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
307
348
  if (skillContentHashes.get(skillPath) === hash) return; // unchanged
308
349
 
309
350
  if (!isNew && localLogger) {
310
- try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'modified', reason: `Skill modified: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path) } }); } catch {}
351
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'modified', reason: `${(type || 'skill')} modified: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), type: type || 'skill' } }); } catch {}
311
352
  }
312
353
 
313
354
  const installId = getInstallId();
314
- scanSkillAsync(skillPath, files, hash, installId);
355
+ scanSkillAsync(skillPath, files, hash, installId, type, skippedFiles);
315
356
  }
316
357
 
317
- function watchSkillDirectory(dir) {
358
+ function watchSkillDirectory(dir, type) {
318
359
  const debounceTimers = new Map();
319
360
  try {
320
361
  const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
@@ -323,19 +364,19 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
323
364
  if (debounceTimers.has(skillDir)) clearTimeout(debounceTimers.get(skillDir));
324
365
  debounceTimers.set(skillDir, setTimeout(() => {
325
366
  debounceTimers.delete(skillDir);
326
- // Re-discover skills and scan changed ones
327
- const skills = collectSkillEntries(dir);
328
- const currentPaths = new Set(skills.map(s => s.skillPath));
329
- for (const { skillPath } of skills) {
330
- scanSkillIfChanged(skillPath);
367
+ // Re-discover entries and scan changed ones
368
+ const entries = type === 'plugin' ? getPluginEntries() : collectSkillEntries(dir);
369
+ const currentPaths = new Set(entries.map(s => s.skillPath));
370
+ for (const { skillPath, type: entryType } of entries) {
371
+ scanSkillIfChanged(skillPath, entryType || type);
331
372
  }
332
- // Detect deleted skills: any known skill in this dir that no longer exists
373
+ // Detect deleted entries: any known path in this dir that no longer exists
333
374
  for (const knownPath of skillContentHashes.keys()) {
334
375
  if (knownPath.startsWith(dir) && !currentPaths.has(knownPath)) {
335
376
  flaggedSkills.delete(knownPath);
336
377
  skillContentHashes.delete(knownPath);
337
378
  const installId = getInstallId();
338
- logSkillRemoved(knownPath, installId);
379
+ logSkillRemoved(knownPath, installId, type);
339
380
  saveScanCache();
340
381
  }
341
382
  }
@@ -388,22 +429,33 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
388
429
  for (const dir of dirs) {
389
430
  const skills = collectSkillEntries(dir);
390
431
  totalSkills += skills.length;
391
- for (const { skillPath } of skills) {
392
- scanSkillIfChanged(skillPath);
432
+ for (const { skillPath, type } of skills) {
433
+ scanSkillIfChanged(skillPath, type);
393
434
  }
394
435
  watchSkillDirectory(dir);
395
436
  }
437
+
438
+ // Scan plugins (entire plugin directories, not just skills/)
439
+ const pluginEntries = getPluginEntries();
440
+ totalSkills += pluginEntries.length;
441
+ for (const { skillPath, type } of pluginEntries) {
442
+ scanSkillIfChanged(skillPath, type);
443
+ }
444
+ // Watch the plugins directory itself for new/changed plugins
445
+ const pluginsDir = path.join(HOME, '.claude', 'plugins');
446
+ try { if (fs.statSync(pluginsDir).isDirectory()) watchSkillDirectory(pluginsDir, 'plugin'); } catch {}
447
+
396
448
  // Always register session so install_id → user_id mapping exists in Supabase
397
449
  registerSession(installId, totalSkills);
398
- track('skill_scanner_init', { skill_dir_count: dirs.length, total_skills: totalSkills });
450
+ track('skill_scanner_init', { skill_dir_count: dirs.length, total_skills: totalSkills, plugin_count: pluginEntries.length });
399
451
  if (localLogger) {
400
- try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'init', reason: `Skill scanner initialized: ${totalSkills} skills in ${dirs.length} directories`, detail: { skill_dir_count: dirs.length, total_skills: totalSkills, directories: dirs } }); } catch {}
452
+ try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'init', reason: `Skill scanner initialized: ${totalSkills} skills/plugins in ${dirs.length} directories`, detail: { skill_dir_count: dirs.length, total_skills: totalSkills, plugin_count: pluginEntries.length, directories: dirs } }); } catch {}
401
453
  // Log each skill individually so dashboard can show per-skill status
402
454
  for (const dir of dirs) {
403
455
  const skills = collectSkillEntries(dir);
404
- for (const { skillPath, skillName } of skills) {
456
+ for (const { skillPath, skillName, type } of skills) {
405
457
  try {
406
- const { files, binaryFiles } = readSkillFiles(skillPath);
458
+ const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
407
459
  const cached = skillContentHashes.has(skillPath);
408
460
  const flagged = flaggedSkills.get(skillPath);
409
461
  localLogger.logLocal({
@@ -417,14 +469,44 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
417
469
  binary_count: binaryFiles.length,
418
470
  file_names: files.map(f => f.relative_path),
419
471
  binary_files: binaryFiles,
472
+ skipped_files: skippedFiles.length > 0 ? skippedFiles : undefined,
473
+ total_size_bytes: totalSize,
420
474
  cached,
421
475
  status: flagged?.suspicious ? 'malicious' : (binaryFiles.length > 0 ? 'binary' : 'clean'),
422
476
  flagged_reason: flagged?.reason || null,
477
+ type: type || 'skill',
423
478
  },
424
479
  });
425
480
  } catch {}
426
481
  }
427
482
  }
483
+ // Log each plugin individually
484
+ for (const { skillPath, skillName, type } of pluginEntries) {
485
+ try {
486
+ const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
487
+ const cached = skillContentHashes.has(skillPath);
488
+ const flagged = flaggedSkills.get(skillPath);
489
+ localLogger.logLocal({
490
+ event: 'guard_check', guard: 'skill', decision: 'init_skill',
491
+ reason: `Plugin discovered: ${skillName}`,
492
+ detail: {
493
+ skill_name: skillName,
494
+ skill_path: skillPath,
495
+ skill_dir: pluginsDir,
496
+ file_count: files.length,
497
+ binary_count: binaryFiles.length,
498
+ file_names: files.map(f => f.relative_path),
499
+ binary_files: binaryFiles,
500
+ skipped_files: skippedFiles.length > 0 ? skippedFiles : undefined,
501
+ total_size_bytes: totalSize,
502
+ cached,
503
+ status: flagged?.suspicious ? 'malicious' : (binaryFiles.length > 0 ? 'binary' : 'clean'),
504
+ flagged_reason: flagged?.reason || null,
505
+ type: 'plugin',
506
+ },
507
+ });
508
+ } catch {}
509
+ }
428
510
  }
429
511
  }
430
512
 
@@ -79,6 +79,12 @@ const exfilGuard = require('./monitor/exfil_guard')({
79
79
  readFileSync: _originalReadFileSync,
80
80
  });
81
81
 
82
+ // === Plugin Sandbox Guard ===
83
+ const pluginGuard = require('./monitor/plugin_guard')({
84
+ localLogger,
85
+ analytics,
86
+ });
87
+
82
88
  // === Prompt Injection Guard (PostToolUse) ===
83
89
  function loadAnthropicKey() {
84
90
  if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
@@ -288,6 +294,15 @@ function hookAllSpawnMethods(cp) {
288
294
  if (cp.spawn && !cp.spawn.__hooked) {
289
295
  const orig = cp.spawn;
290
296
  cp.spawn = function(command, args, options) {
297
+ // Plugin sandbox: scrub env before spawning plugin processes
298
+ // Only scrub from the parent (non-sandboxed) process — if we're already
299
+ // sandboxed, the env is already minimal and the plugin may have added
300
+ // its own vars (e.g. CAMOFOX_PORT) that must be preserved.
301
+ if (!pluginGuard.isSandboxed() && pluginGuard.isPluginSpawn(command, args)) {
302
+ if (!options || typeof options !== 'object') options = {};
303
+ options.env = pluginGuard.scrubEnvForPlugin(options.env || process.env);
304
+ arguments[2] = options;
305
+ }
291
306
  const shellCmd = extractShellCommand(command, args);
292
307
  if (shellCmd) {
293
308
  const block = shouldBlockCommand(shellCmd);
@@ -320,6 +335,12 @@ function hookAllSpawnMethods(cp) {
320
335
  if (cp.spawnSync && !cp.spawnSync.__hooked) {
321
336
  const orig = cp.spawnSync;
322
337
  cp.spawnSync = function(command, args, options) {
338
+ // Plugin sandbox: scrub env before spawning plugin processes (same guard as spawn)
339
+ if (!pluginGuard.isSandboxed() && pluginGuard.isPluginSpawn(command, args)) {
340
+ if (!options || typeof options !== 'object') options = {};
341
+ options.env = pluginGuard.scrubEnvForPlugin(options.env || process.env);
342
+ arguments[2] = options;
343
+ }
323
344
  const shellCmd = extractShellCommand(command, args);
324
345
  if (shellCmd) {
325
346
  const block = shouldBlockCommand(shellCmd);
@@ -488,14 +509,18 @@ function hookAllSpawnMethods(cp) {
488
509
  // === fs hooks ===
489
510
 
490
511
  function hookFsMethods(fsModule) {
491
- if (fsModule.readFileSync && !fsModule.readFileSync.__hooked) {
492
- const orig = fsModule.readFileSync;
493
- fsModule.readFileSync = function(filePath, options) {
494
- // Pass-through: blocking here disrupts openclaw's own config/LLM operations.
495
- // Agent actions are blocked at child_process level instead.
496
- return orig.apply(this, arguments);
497
- };
498
- fsModule.readFileSync.__hooked = true;
512
+ // Sandbox FS blocklist — only active in sandboxed plugin processes
513
+ const fsMethodsToGuard = ['readFileSync', 'readFile', 'existsSync', 'statSync', 'readdirSync', 'accessSync'];
514
+ for (const method of fsMethodsToGuard) {
515
+ if (fsModule[method] && !fsModule[method].__hooked) {
516
+ const orig = fsModule[method];
517
+ fsModule[method] = function(filePath) {
518
+ const fsBlock = pluginGuard.checkFsAccess(filePath);
519
+ if (fsBlock) { const e = new Error(fsBlock.reason); e.code = 'EACCES'; throw e; }
520
+ return orig.apply(this, arguments);
521
+ };
522
+ fsModule[method].__hooked = true;
523
+ }
499
524
  }
500
525
  }
501
526
 
@@ -505,8 +530,17 @@ function hookHttpModule(mod, protocol) {
505
530
  if (mod.request && !mod.request.__hooked) {
506
531
  const orig = mod.request;
507
532
  mod.request = function(options, callback) {
508
- // Pass-through: blocking here kills openclaw's LLM API calls.
509
- // Agent actions are blocked at child_process level instead.
533
+ // Sandbox network logging log outbound requests from plugin processes
534
+ try {
535
+ const opts = typeof options === 'string' ? new URL(options) : options;
536
+ pluginGuard.logNetworkRequest({
537
+ host: opts.hostname || opts.host,
538
+ port: opts.port,
539
+ method: opts.method || 'GET',
540
+ path: opts.path || opts.pathname,
541
+ protocol: protocol || 'https:',
542
+ });
543
+ } catch {}
510
544
  return orig.apply(this, arguments);
511
545
  };
512
546
  mod.request.__hooked = true;
@@ -515,6 +549,16 @@ function hookHttpModule(mod, protocol) {
515
549
  if (mod.get && !mod.get.__hooked) {
516
550
  const orig = mod.get;
517
551
  mod.get = function(options, callback) {
552
+ try {
553
+ const opts = typeof options === 'string' ? new URL(options) : options;
554
+ pluginGuard.logNetworkRequest({
555
+ host: opts.hostname || opts.host,
556
+ port: opts.port,
557
+ method: 'GET',
558
+ path: opts.path || opts.pathname,
559
+ protocol: protocol || 'https:',
560
+ });
561
+ } catch {}
518
562
  return orig.apply(this, arguments);
519
563
  };
520
564
  mod.get.__hooked = true;
@@ -527,8 +571,19 @@ function hookGlobalFetch() {
527
571
  if (!globalThis.fetch || globalThis.fetch.__hooked) return;
528
572
  const origFetch = globalThis.fetch;
529
573
  globalThis.fetch = function(url, options) {
530
- // Pass-through: blocking here kills openclaw's LLM API calls.
531
- // Agent actions are blocked at child_process level instead.
574
+ // Sandbox network logging log outbound fetch from plugin processes
575
+ try {
576
+ const u = typeof url === 'string' ? new URL(url) : (url instanceof URL ? url : (url && url.url ? new URL(url.url) : null));
577
+ if (u) {
578
+ pluginGuard.logNetworkRequest({
579
+ host: u.hostname,
580
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
581
+ method: (options && options.method) || 'GET',
582
+ path: u.pathname + u.search,
583
+ protocol: u.protocol,
584
+ });
585
+ }
586
+ } catch {}
532
587
  return origFetch.apply(this, arguments);
533
588
  };
534
589
  globalThis.fetch.__hooked = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextfort-ai/openclaw-secure",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Runtime security guard for OpenClaw — blocks malicious commands before they execute",
5
5
  "bin": {
6
6
  "openclaw-secure": "./bin/openclaw-secure.js"