From af8c2fe6a95cb0bb88509fecae7f48d6c9f60a90 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 24 Apr 2026 06:55:19 +0800 Subject: [PATCH] feat(skill-market): security scan visibility, rescan action, pagination fix --- .../skill/controller/SkillController.java | 11 +- .../vip/mate/skill/model/SkillEntity.java | 13 + .../skill/runtime/SkillPackageResolver.java | 70 +++++ .../skill/runtime/SkillRuntimeService.java | 15 ++ .../vip/mate/skill/service/SkillService.java | 30 ++- mateclaw-ui/src/api/index.ts | 6 +- mateclaw-ui/src/i18n/locales/en-US.ts | 13 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 13 + mateclaw-ui/src/types/index.ts | 4 + mateclaw-ui/src/views/SkillMarket.vue | 248 ++++++++++++++++-- 10 files changed, 404 insertions(+), 19 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index 7d0c41fd..b9d7b157 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -42,8 +42,9 @@ public class SkillController { @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String keyword, @RequestParam(required = false) String skillType, - @RequestParam(required = false) Boolean enabled) { - return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled)); + @RequestParam(required = false) Boolean enabled, + @RequestParam(required = false) String scanStatus) { + return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus)); } @Operation(summary = "获取各类型技能计数(tab 徽章用)") @@ -52,6 +53,12 @@ public class SkillController { return R.ok(skillService.countByType()); } + @Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)") + @PostMapping("/{id}/rescan") + public R rescan(@PathVariable Long id) { + return R.ok(skillService.rescanSecurity(id)); + } + @Operation(summary = "获取已启用技能列表") @GetMapping("/enabled") public R> listEnabled() { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index 90fda726..c7521752 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -84,6 +84,19 @@ public class SkillEntity { */ private String securityScanStatus; + /** + * RFC-042 §2.3 — persisted JSON array of the last scan's findings + * ({@code [{ruleId,severity,category,title,description,filePath, + * lineNumber,snippet,remediation}]}). Populated by + * {@code SkillPackageResolver} after every scan so the admin UI can + * render "why blocked" without re-resolving. + */ + @TableField(value = "security_scan_result", updateStrategy = FieldStrategy.ALWAYS) + private String securityScanResult; + + /** RFC-042 §2.3 — wall-clock time of the last scan write-back. */ + private LocalDateTime securityScanTime; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index aee8b9c2..32859256 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -5,14 +5,17 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.SkillWorkspaceManager; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; /** @@ -38,6 +41,7 @@ public class SkillPackageResolver { private final SkillDependencyChecker dependencyChecker; private final ObjectMapper objectMapper; private final SkillWorkspaceManager workspaceManager; + private final SkillMapper skillMapper; /** * 解析技能实体为运行时技能包(完整流程) @@ -72,9 +76,75 @@ public class SkillPackageResolver { // 4. 综合判定 runtimeAvailable resolveRuntimeAvailability(resolved); + // 5. RFC-042 §2.3 — persist the scan outcome so the admin UI can show + // findings after a restart (previously they lived only in memory). + persistScanOutcome(entity, resolved); + return resolved; } + /** + * Write back the latest scan status / findings JSON / timestamp when + * they differ from what's already on the row. Keeps the DB in sync + * without re-writing on every idempotent refresh. + * + *

Diff-based so a fresh resolve loop across N enabled skills is + * effectively free when nothing has changed on disk. Errors here are + * non-fatal — the scan result is already attached to {@code resolved}, + * so the UI will still see it for this request. + */ + private void persistScanOutcome(SkillEntity entity, ResolvedSkill resolved) { + if (entity == null || entity.getId() == null) return; + + String newStatus = deriveScanStatus(resolved); + String newJson = serializeFindings(resolved.getSecurityFindings()); + boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus); + boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson); + + if (!statusChanged && !findingsChanged) { + return; + } + + try { + SkillEntity update = new SkillEntity(); + update.setId(entity.getId()); + update.setSecurityScanStatus(newStatus); + update.setSecurityScanResult(newJson); + update.setSecurityScanTime(LocalDateTime.now()); + skillMapper.updateById(update); + // Keep the in-memory entity coherent with the DB so the next + // resolve in the same tick doesn't redundantly write again. + entity.setSecurityScanStatus(newStatus); + entity.setSecurityScanResult(newJson); + entity.setSecurityScanTime(update.getSecurityScanTime()); + } catch (Exception e) { + log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage()); + } + } + + /** + * Collapse the resolver's rich security state back into the {@code + * PASSED / FAILED / null} tri-state used on the row. + */ + private String deriveScanStatus(ResolvedSkill resolved) { + if (resolved.isSecurityBlocked()) return "FAILED"; + List findings = resolved.getSecurityFindings(); + if (findings != null && !findings.isEmpty()) return "PASSED"; // scanned and found non-blocking issues + // No block, no findings — treat as scanned-clean (still PASSED so + // listEnabledSkills() doesn't treat it as never-scanned). + return "PASSED"; + } + + private String serializeFindings(List findings) { + if (findings == null || findings.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(findings); + } catch (Exception e) { + log.debug("Failed to serialize findings: {}", e.getMessage()); + return null; + } + } + // ==================== 阶段 1:内容解析 ==================== private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 761ba0c4..47cfe4c2 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -104,6 +104,21 @@ public class SkillRuntimeService { .collect(Collectors.toList()); } + /** + * Rescan one skill on demand (RFC-042 §2.3.4) — runs the full resolver + * pipeline (content + security + dependency), which writes the updated + * scan result to DB as a side-effect, and then invalidates the active + * skills cache so subsequent reads reflect the new status. + */ + public ResolvedSkill rescanSingle(SkillEntity skill) { + ResolvedSkill resolved = packageResolver.resolve(skill); + activeSkillsCache.invalidateAll(); + log.info("Rescanned skill '{}' (id={}): status={}, blocked={}", + skill.getName(), skill.getId(), + skill.getSecurityScanStatus(), resolved.isSecurityBlocked()); + return resolved; + } + /** * 根据名称查找 active skill */ diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index ad8f51e6..51eafe5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -67,9 +67,15 @@ public class SkillService { *

RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters * are all optional; empty or {@code null} means "no filter". Keyword * searches name / description / tags with LIKE. + * + *

{@code scanStatus} (RFC-042 §2.3.5) filters on {@code + * security_scan_status}: {@code "FAILED"} surfaces blocked skills so the + * admin can inspect findings and rescan, {@code "PASSED"} shows scanned + * clean rows, {@code null} / empty means no scan filter. */ public IPage pageSkills(int page, int size, String keyword, - String skillType, Boolean enabled) { + String skillType, Boolean enabled, + String scanStatus) { Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); @@ -86,6 +92,9 @@ public class SkillService { if (enabled != null) { wrapper.eq(SkillEntity::getEnabled, enabled); } + if (scanStatus != null && !scanStatus.isBlank()) { + wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase()); + } wrapper.orderByDesc(SkillEntity::getBuiltin) .orderByDesc(SkillEntity::getCreateTime); @@ -93,6 +102,25 @@ public class SkillService { return skillMapper.selectPage(pageParam, wrapper); } + /** + * Manually re-run security + dependency resolution for a single skill + * (RFC-042 §2.3.4). Triggered from the admin UI after the user fixes + * flagged code and wants an immediate verdict instead of waiting for + * the next refresh event. + * + *

The resolver itself persists the outcome — this method just kicks + * it and returns the reloaded row. + */ + public SkillEntity rescanSecurity(Long id) { + SkillEntity skill = getSkill(id); // throws MateClawException if missing + if (runtimeService == null) { + throw new MateClawException("err.skill.runtime_unavailable", + "Skill runtime not initialized yet; retry in a moment"); + } + runtimeService.rescanSingle(skill); + return skillMapper.selectById(id); + } + /** * Aggregate skill counts per {@code skill_type}, plus an {@code all} * rollup. Feeds the SkillMarket tab badges without pulling every row. diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index e9b78996..9509d994 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -144,16 +144,20 @@ export const conversationApi = { // ==================== Skill ==================== export const skillApi = { - /** RFC-042 §2.1 — paginated skill listing with search/type/enabled filters */ + /** RFC-042 §2.1 — paginated skill listing with search/type/enabled/scanStatus filters */ page: (params: { page?: number size?: number keyword?: string skillType?: string enabled?: boolean + /** 'PASSED' / 'FAILED' — filters by security_scan_status (RFC-042 §2.3.5) */ + scanStatus?: string } = {}) => http.get('/skills', { params }), /** Tab count aggregate — returns { all, builtin, mcp, dynamic } */ counts: () => http.get('/skills/counts'), + /** RFC-042 §2.3.4 — manually rescan a single skill's security */ + rescan: (id: string | number) => http.post(`/skills/${id}/rescan`), listEnabled: () => http.get('/skills/enabled'), get: (id: string | number) => http.get(`/skills/${id}`), create: (data: any) => http.post('/skills', data), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index be64eca1..c9f1b2ae 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1613,6 +1613,19 @@ export default { all: 'All statuses', enabled: 'Enabled', disabled: 'Disabled', + scanFailed: 'Scan failed', + }, + security: { + scanned: 'Scanned', + scanFailed: 'Scan failed', + findingsTitle: 'Security scan failed', + rescan: 'Rescan', + rescanning: 'Scanning...', + rescanPassed: 'Rescan passed', + rescanStillFailed: 'Rescan still failed — see findings below', + rescanFailed: 'Rescan request failed', + noPersistedFindings: 'No persisted findings for this scan. Click "Rescan" to regenerate.', + fix: 'Suggested fix', }, empty: 'No skills found', emptyDesc: 'Add skills to enhance your agents\' capabilities', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 0bddbbb5..d993424f 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1623,6 +1623,19 @@ export default { all: '全部状态', enabled: '已启用', disabled: '已禁用', + scanFailed: '扫描失败', + }, + security: { + scanned: '已扫描', + scanFailed: '扫描失败', + findingsTitle: '安全扫描失败', + rescan: '重新扫描', + rescanning: '扫描中...', + rescanPassed: '重新扫描通过', + rescanStillFailed: '重新扫描仍未通过,请查看详情', + rescanFailed: '重新扫描失败', + noPersistedFindings: '本次尚无持久化的扫描详情,点击"重新扫描"以重新生成。', + fix: '修复建议', }, empty: '暂无技能', emptyDesc: '添加技能以增强 Agent 的能力', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 8c11283f..8e50a9ec 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -212,6 +212,10 @@ export interface Skill { sourceConversationId?: string /** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */ securityScanStatus?: string + /** RFC-042 §2.3 — JSON-serialised SkillSecurityFinding[] from last scan */ + securityScanResult?: string + /** RFC-042 §2.3 — wall-clock time of the last scan */ + securityScanTime?: string } /** 运行时解析状态(来自 /runtime/status) */ diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index b49890bd..7d4779b7 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -49,10 +49,11 @@ type="search" :placeholder="t('skills.search.placeholder')" /> - - - + + + @@ -89,12 +90,19 @@ 🤖 AI - - - 🛡️ Scan Failed - + + - ✓ Scanned + ✓ {{ t('skills.security.scanned') }} @@ -117,6 +125,52 @@

{{ getRuntimeError(skill) }}
+ +
+
+ + {{ t('skills.security.findingsTitle') }} + + · {{ formatScanTime(skill.securityScanTime) }} + + + +
+
    +
  • +
    + [{{ f.severity || 'INFO' }}] + {{ f.ruleId || f.category || '—' }} + + {{ f.filePath }}:{{ f.lineNumber }} + +
    +
    {{ f.title }}
    +
    {{ f.description }}
    +
    + {{ t('skills.security.fix') }}: {{ f.remediation }} +
    +
  • +
+
+ {{ t('skills.security.noPersistedFindings') }} +
+
+
{{ tag }}
@@ -154,9 +208,10 @@ class="skill-pagination" v-model:current-page="query.page" v-model:page-size="query.size" - :page-sizes="[12, 24, 48]" + :page-sizes="[10, 20, 50]" :total="total" - layout="total, sizes, prev, pager, next" + :hide-on-single-page="false" + layout="total, sizes, prev, pager, next, jumper" background @size-change="onPageSizeChange" @current-change="loadSkills" @@ -268,16 +323,20 @@ const editingSkill = ref(null) const refreshing = ref(false) const showImportDialog = ref(false) -/** Paginated query state — RFC-042 §2.1 */ +/** Paginated query state — RFC-042 §2.1 + §2.3.5 */ const query = reactive({ page: 1, - size: 24, + size: 10, keyword: '', skillType: 'all' as string, - /** '' = all, 'true' = enabled only, 'false' = disabled only (string to avoid tri-state checkbox quirks) */ - enabledFilter: '' as string, + /** '' = all | 'enabled' | 'disabled' | 'scan_failed' (RFC-042 §2.3.5 unified status filter) */ + statusFilter: '' as string, }) +/** Per-skill UI state for the RFC-042 §2.3 findings panel. */ +const expandedFindings = ref>({}) +const rescanning = ref>({}) + const categoryTabs = computed(() => [ { label: t('skills.tabs.all'), value: 'all', icon: '🗂️' }, { label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' }, @@ -349,7 +408,11 @@ async function loadSkills() { const params: Record = { page: query.page, size: query.size } if (query.keyword) params.keyword = query.keyword.trim() if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType - if (query.enabledFilter !== '') params.enabled = query.enabledFilter === 'true' + // Map the single status filter onto the backend's two independent params: + // enabled (bool) and scanStatus (PASSED/FAILED). scan_failed implies any enabled state. + if (query.statusFilter === 'enabled') params.enabled = true + else if (query.statusFilter === 'disabled') params.enabled = false + else if (query.statusFilter === 'scan_failed') params.scanStatus = 'FAILED' const res: any = await skillApi.page(params) const data = res.data || {} @@ -467,6 +530,60 @@ async function handleRefreshRuntime() { } } +// ==================== RFC-042 §2.3 — persisted scan findings ==================== + +/** Parse the DB-persisted JSON findings array. Returns [] on any parse error. */ +function parsedFindings(skill: Skill): SkillSecurityFinding[] { + const raw = skill.securityScanResult + if (!raw) return [] + try { + const arr = JSON.parse(raw) + return Array.isArray(arr) ? (arr as SkillSecurityFinding[]) : [] + } catch { + return [] + } +} + +function toggleFindings(skill: Skill) { + const key = String(skill.id) + expandedFindings.value = { ...expandedFindings.value, [key]: !expandedFindings.value[key] } +} + +async function rescanSkill(skill: Skill) { + const key = String(skill.id) + rescanning.value = { ...rescanning.value, [key]: true } + try { + const res: any = await skillApi.rescan(skill.id) + const updated: Skill | undefined = res?.data + if (updated) { + // Patch the row in-place so the panel updates without a full page reload. + const idx = skills.value.findIndex(s => s.id === skill.id) + if (idx >= 0) skills.value.splice(idx, 1, { ...skills.value[idx], ...updated }) + ElMessage.success( + updated.securityScanStatus === 'FAILED' + ? t('skills.security.rescanStillFailed') + : t('skills.security.rescanPassed') + ) + } + // Refresh runtime status too so the in-memory badges stay in sync. + await loadRuntimeStatus() + } catch (e: any) { + ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.security.rescanFailed')) + } finally { + rescanning.value = { ...rescanning.value, [key]: false } + } +} + +function formatScanTime(iso: string): string { + try { + const d = new Date(iso) + if (isNaN(d.getTime())) return iso + return d.toLocaleString() + } catch { + return iso + } +} + // ==================== Runtime Display Helpers ==================== function getRuntimeStatus(skill: Skill): SkillRuntimeStatus | null { @@ -699,6 +816,107 @@ html.dark .skill-pagination :deep(.el-pagination .el-select .el-input__wrapper) margin-right: 12px; } +/* RFC-042 §2.3 — security scan findings panel (frosted, non-EP) */ +.scan-badge-button { + border: none; + cursor: pointer; + font: inherit; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 8px; +} +.scan-badge-button:hover { filter: brightness(0.97); } +.scan-badge-chevron { font-size: 10px; opacity: 0.75; } + +.scan-findings-panel { + margin-top: 10px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 95, 86, 0.08); + border: 1px solid rgba(255, 95, 86, 0.22); + backdrop-filter: blur(8px) saturate(1.05); + -webkit-backdrop-filter: blur(8px) saturate(1.05); +} +html.dark .scan-findings-panel { + background: rgba(255, 95, 86, 0.12); + border-color: rgba(255, 95, 86, 0.28); +} +.scan-findings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.scan-findings-title { + font-size: 12px; + font-weight: 600; + color: var(--mc-text-primary); +} +.scan-findings-time { + font-weight: 400; + font-size: 11px; + color: var(--mc-text-tertiary); +} +.scan-rescan-btn { + height: 26px; + padding: 0 10px; + border-radius: 8px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.55); + color: var(--mc-text-primary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +html.dark .scan-rescan-btn { + background: rgba(255, 255, 255, 0.08); +} +.scan-rescan-btn:hover:not(:disabled) { + background: var(--mc-primary); + color: #fff; +} +.scan-rescan-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.scan-findings-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.scan-finding-item { + padding: 7px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.45); + border-left: 3px solid var(--mc-border); + font-size: 12px; + line-height: 1.5; +} +html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); } +.scan-finding-item.sev-critical { border-left-color: #d32f2f; } +.scan-finding-item.sev-high { border-left-color: #f57c00; } +.scan-finding-item.sev-medium { border-left-color: #fbc02d; } +.scan-finding-item.sev-low { border-left-color: #689f38; } +.scan-finding-head { + display: flex; + align-items: baseline; + gap: 6px; + flex-wrap: wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} +.scan-finding-sev { font-weight: 700; color: var(--mc-text-primary); } +.scan-finding-id { color: var(--mc-text-secondary); } +.scan-finding-loc { color: var(--mc-text-tertiary); } +.scan-finding-title { font-weight: 600; margin-top: 3px; color: var(--mc-text-primary); } +.scan-finding-desc { color: var(--mc-text-secondary); margin-top: 2px; } +.scan-finding-fix { color: var(--mc-text-secondary); margin-top: 4px; font-style: italic; } +.scan-findings-empty { + font-size: 12px; + color: var(--mc-text-tertiary); + font-style: italic; +} + /* 技能网格 */ .skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; } .skill-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }