@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.
- package/monitor/dashboard/public/index.html +137 -15
- package/monitor/dashboard/server.js +5 -0
- package/monitor/plugin_guard/index.js +194 -0
- package/monitor/skills_guard/index.js +121 -39
- package/openclaw-secure.js +67 -12
- package/package.json +1 -1
|
@@ -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(
|
|
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 & 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 & 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 · <b id="g-sandbox-fs">0</b> FS blocks · <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 & 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.
|
|
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>
|
|
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">⚠ ${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>
|
|
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;
|
|
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,
|
|
122
|
+
walk(full, rel);
|
|
102
123
|
} else if (e.isFile()) {
|
|
103
124
|
try {
|
|
104
125
|
const stat = fs.statSync(full);
|
|
105
|
-
if (stat.size
|
|
106
|
-
|
|
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(
|
|
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:
|
|
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: `
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
327
|
-
const
|
|
328
|
-
const currentPaths = new Set(
|
|
329
|
-
for (const { skillPath } of
|
|
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
|
|
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
|
|
package/openclaw-secure.js
CHANGED
|
@@ -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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
//
|
|
509
|
-
|
|
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
|
-
//
|
|
531
|
-
|
|
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